feat:消费机库存扣减扣每日菜谱的线下库存
This commit is contained in:
1 parent
493e7ef804
commit
b0b5c89c0f
2 files changed
+204
-49
No files matched your search
+91
-43
@@ -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 库存操作,
|
||||
* 覆盖远程成功但本地提交失败的窗口。
|
||||
|
||||
+113
-6
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造线上预定提交的每日菜谱订单。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user