feat:消费机库存扣减扣每日菜谱的线下库存

This commit is contained in:
dzc committed 2026-09-14 09:59:10 +08:00
1 parent 493e7ef804
commit b0b5c89c0f
2 files changed
+204 -49

No files matched your search

@@ -4643,30 +4643,28 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
LinkedHashMap::new));
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
boolean dailyMenu = isDailyMenuParentCar(parentCar);
// 带菜谱标记的购物车(App 预约每日菜谱)整单走分池;设备购物车(createCard 不写 cartScene)
// 按「商户+订单日期+餐段」菜谱在售情况逐商品分流:在册商品扣每日菜谱分池,其余继续扣基础 SPU 库存。
boolean markedDailyMenu = isDailyMenuParentCar(parentCar);
// 每日菜谱分池路由:线上预定订单下单扣 ONLINE 池(updateDailyMenuReserveInventory),退款/重扣必须还同一池;
// 机器收银等其他类型下单走 OFFLINE 池。不看订单类型会把预约单的线上销量错补到线下池。
boolean onlineChannel = HardOrderTypeEnum.RESERVE.getType().equals(order.getOrderType());
String dailyStockChannel = onlineChannel ? "ONLINE" : "OFFLINE";
LocalDate mealDate = order.getApptUseDate();
Integer hourType = parseHourType(order.getHourType());
Map<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
Map<Long, Integer> dailyChanges;
Map<Long, Integer> basicChanges = Map.of();
if (dailyMenu) {
if (markedDailyMenu) {
dailyChanges = stockChanges;
if (mealDate == null || hourType == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
// 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @NoToken 内部菜谱接口;
// getCanteenList 是 AD 终端登录态接口,任务线程调用会被 424 拒绝。
R<List<DailyMenuAppVO>> menuResult = remoteDailyMenuService.listForOrderValidate(
mealDate.toString(), hourType, order.getMerchantId());
if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
: ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, DailyMenuAppVO> menuBySpu = menuResult.getData().stream()
.filter(Objects::nonNull)
.filter(row -> row.getSpuId() != null)
.collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
Map<Long, DailyMenuAppVO> menuBySpu = loadDailyMenuForInventorySplit(
mealDate, hourType, order.getMerchantId(), false);
for (Map.Entry<Long, Integer> entry : stockChanges.entrySet()) {
DailyMenuAppVO row = menuBySpu.get(entry.getKey());
if (row == null) {
@@ -4680,34 +4678,55 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
else {
R<List<CanteenSpuEntity>> productResult = remoteCanteenSpuService
.getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet()));
if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
if (productResult == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
}
if (StrUtil.isNotBlank(productResult.getMsg())) {
throw new RuntimeException(productResult.getMsg());
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
// 设备购买当日菜谱:按订单日期餐段在售情况分流,日期或餐段缺失时全部按基础 SPU 库存处理。
Map<Long, DailyMenuAppVO> menuBySpu = loadDailyMenuForInventorySplit(
mealDate, hourType, order.getMerchantId(), true);
dailyChanges = new LinkedHashMap<>();
Map<Long, Integer> unresolvedChanges = new LinkedHashMap<>();
for (Map.Entry<Long, Integer> entry : stockChanges.entrySet()) {
CanteenSpuEntity product = productMap.get(entry.getKey());
if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
DailyMenuAppVO row = menuBySpu.get(entry.getKey());
if (row == null) {
unresolvedChanges.put(entry.getKey(), entry.getValue());
continue;
}
int productType = product.getSpuType() == null ? 1 : product.getSpuType();
if (productType != 1 && productType != 2) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
int channelRemain = onlineChannel ? dailyOnlineRemain(row) : dailyOfflineRemain(row);
if (entry.getValue() < 0 && channelRemain < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName());
}
if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
inventorySnapshot.put(entry.getKey(), channelRemain);
dailyChanges.put(entry.getKey(), entry.getValue());
}
basicChanges = unresolvedChanges;
if (!basicChanges.isEmpty()) {
R<List<CanteenSpuEntity>> productResult = remoteCanteenSpuService
.getInnerSpuListByIds(new ArrayList<>(basicChanges.keySet()));
if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
if (productResult == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
}
if (StrUtil.isNotBlank(productResult.getMsg())) {
throw new RuntimeException(productResult.getMsg());
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
for (Map.Entry<Long, Integer> entry : basicChanges.entrySet()) {
CanteenSpuEntity product = productMap.get(entry.getKey());
if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
int productType = product.getSpuType() == null ? 1 : product.getSpuType();
if (productType != 1 && productType != 2) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
}
if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
}
inventorySnapshot.put(entry.getKey(), product.getStock());
}
inventorySnapshot.put(entry.getKey(), product.getStock());
}
}
if (validateOnly) return;
@@ -4717,19 +4736,48 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
String resolvedInventoryBizId = StrUtil.isNotBlank(inventoryBizId) ? inventoryBizId : afterSaleId == null
? "HARD_DAILY_MENU_ORDER:" + order.getId()
: "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId;
if (dailyMenu) {
String stockChannel = onlineChannel ? "ONLINE" : "OFFLINE";
registerCanteenDailyChannelInventoryRollback(stockChannel, stockChanges, inventorySnapshot,
if (!dailyChanges.isEmpty()) {
registerCanteenDailyChannelInventoryRollback(dailyStockChannel, dailyChanges, inventorySnapshot,
resolvedInventoryBizId, order.getMerchantId(), mealDate, hourType);
applyCanteenDailyChannelInventoryChange(stockChannel, stockChanges, inventorySnapshot,
applyCanteenDailyChannelInventoryChange(dailyStockChannel, dailyChanges, inventorySnapshot,
resolvedInventoryBizId, "APPLY", order.getMerchantId(), mealDate, hourType);
}
else {
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
if (!basicChanges.isEmpty()) {
registerCanteenSpuInventoryRollback(basicChanges, inventorySnapshot, resolvedInventoryBizId);
applyCanteenSpuInventoryChange(basicChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
}
}
/**
* 查询商户在指定日期餐段仍在售的每日菜谱,供库存路由逐商品判断:在册商品走分池,其余走基础 SPU 库存。
*
* @param mealDate 订单用餐日期;为空且允许跳过时返回空表
* @param hourType 订单餐段;为空且允许跳过时返回空表
* @param merchantId 订单商户
* @param skipWhenUnknown 设备订单(无菜谱标记)日期餐段缺失时降级为基础库存;带标记购物车必须可查,缺失即失败
* @return spuId 到菜谱行的映射;无可售菜谱时为空表
*/
private Map<Long, DailyMenuAppVO> loadDailyMenuForInventorySplit(LocalDate mealDate, Integer hourType,
Long merchantId, boolean skipWhenUnknown) {
if (mealDate == null || hourType == null) {
if (skipWhenUnknown) {
return Map.of();
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
// 必须走 @NoToken 内部菜谱接口:设备支付回调和自动退款任务都没有用户凭证,登录态接口会 424。
R<List<DailyMenuAppVO>> menuResult = remoteDailyMenuService.listForOrderValidate(
mealDate.toString(), hourType, merchantId);
if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
: ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
return menuResult.getData().stream()
.filter(Objects::nonNull)
.filter(row -> row.getSpuId() != null)
.collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
}
/**
* 当前 Hardware 本地事务回滚时反向撤销已经尝试的食堂基础 SPU 库存操作,
* 覆盖远程成功但本地提交失败的窗口。
@@ -188,25 +188,89 @@ class HardOrderServiceImplInventoryTest {
}
/**
* 验证设备购买链路(/hard-api/ad/create-order 产生的 BELOW+CANTEEN_SPU 购物车)扣基础 canteen_spu.stock:
* 请求不携带每日菜谱分池通道,也不查询每日菜谱接口——每日菜谱页面的线上/线下剩余不会变化。
* 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
*/
@Test
void deviceCreateOrderDeductsBasicSpuStockWithoutDailyMenuPools() {
void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
prepareCart(1);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertEquals("OFFLINE", request.getStockChannel());
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
assertEquals(300L, request.getMerchantId());
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());
}
/**
* 验证设备购买不在当日菜谱的商品时仍扣基础 canteen_spu.stock:请求不携带分池通道。
*/
@Test
void deviceCreateOrderDeductsBasicSpuStockWhenNotOnMenu() {
prepareCart(1);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of()));
CanteenSpuEntity product = productWithStock(9996);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null));
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertNull(request.getStockChannel());
assertEquals(-1, request.getItems().get(0).getIncrCount());
verify(remoteDailyMenuService, never()).listForOrderValidate(any(), any(), any());
verify(remoteDailyMenuService, never()).getCanteenList(any(), any(), any(), any());
assertEquals(9996, request.getItems().get(0).getBeforeStock());
}
/**
* 验证混合购物车按商品分流:在册商品扣每日菜谱 OFFLINE 池,不在册商品扣基础 canteen_spu.stock,
* 两次幂等调用共用同一业务号(食堂端状态键按渠道隔离,不会互相命中)。
*/
@Test
void deviceCreateOrderSplitsMixedCartBetweenPools() {
prepareTwoProductCart(1, 2);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
CanteenSpuEntity product = productWithStock(5);
product.setId(12L);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(12L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService, org.mockito.Mockito.times(2)).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO dailyRequest = captor.getAllValues().stream()
.filter(r -> "OFFLINE".equals(r.getStockChannel())).findFirst().orElseThrow();
CanteenSpuUpdateStockReqDTO basicRequest = captor.getAllValues().stream()
.filter(r -> r.getStockChannel() == null).findFirst().orElseThrow();
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());
}
/**
@@ -318,6 +382,31 @@ class HardOrderServiceImplInventoryTest {
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car));
}
/**
* 准备双商品购物车:第一个商品可出现在当日菜谱,第二个不在菜谱,用于混合分流验证。
*
* @param firstCount 第一个商品份数
* @param secondCount 第二个商品份数
*/
private void prepareTwoProductCart(int firstCount, int secondCount) {
HardParentCarEntity parentCar = HardParentCarEntity.builder()
.id(200L)
.productSource(HardProductSourceEnum.CANTEEN_SPU.getType())
.build();
HardCarEntity first = HardCarEntity.builder()
.parentCarId(200L)
.productId(11L)
.productCount(firstCount)
.build();
HardCarEntity second = HardCarEntity.builder()
.parentCarId(200L)
.productId(12L)
.productCount(secondCount)
.build();
when(hardCarService.getParentCarById(200L)).thenReturn(parentCar);
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(first, second));
}
/**
* 构造指定线上/线下剩余的每日菜谱行。
*
@@ -365,6 +454,24 @@ class HardOrderServiceImplInventoryTest {
.build();
}
/**
* 构造带日期餐段的设备购买订单:createOrder 会写入 apptUseDate=今天、hourType=请求餐段,
* 是 /hard/hard-api/ad/create-order 购买每日菜谱时的真实形态。
*
* @return 关联 CANTEEN_SPU 购物车、固定日期餐段和商户的 BELOW 订单
*/
private HardOrderEntity deviceDailyMenuOrder() {
return HardOrderEntity.builder()
.id(100L)
.sysType(HardTypeEnum.CANTEEN.getType())
.orderType(HardOrderTypeEnum.BELOW.getType())
.parentCarId(200L)
.merchantId(300L)
.apptUseDate(LocalDate.of(2026, 9, 13))
.hourType("2")
.build();
}
/**
* 构造线上预定提交的每日菜谱订单。
*