From b0b5c89c0fe93bb66754a41e7d47d460f4888dae Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 09:59:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B6=88=E8=B4=B9=E6=9C=BA=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=89=A3=E5=87=8F=E6=89=A3=E6=AF=8F=E6=97=A5=E8=8F=9C?= =?UTF-8?q?=E8=B0=B1=E7=9A=84=E7=BA=BF=E4=B8=8B=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/order/HardOrderServiceImpl.java | 134 ++++++++++++------ .../HardOrderServiceImplInventoryTest.java | 119 +++++++++++++++- 2 files changed, 204 insertions(+), 49 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 04d410625..0e4ea7023 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -4643,30 +4643,28 @@ public class HardOrderServiceImpl extends ServiceImpl inventorySnapshot = new LinkedHashMap<>(); + Map dailyChanges; + Map 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> 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 menuBySpu = menuResult.getData().stream() - .filter(Objects::nonNull) - .filter(row -> row.getSpuId() != null) - .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a)); + Map menuBySpu = loadDailyMenuForInventorySplit( + mealDate, hourType, order.getMerchantId(), false); for (Map.Entry entry : stockChanges.entrySet()) { DailyMenuAppVO row = menuBySpu.get(entry.getKey()); if (row == null) { @@ -4680,34 +4678,55 @@ public class HardOrderServiceImpl extends ServiceImpl> 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 productMap = productResult.getData().stream() - .filter(Objects::nonNull) - .filter(product -> product.getId() != null) - .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + // 设备购买当日菜谱:按订单日期餐段在售情况分流,日期或餐段缺失时全部按基础 SPU 库存处理。 + Map menuBySpu = loadDailyMenuForInventorySplit( + mealDate, hourType, order.getMerchantId(), true); + dailyChanges = new LinkedHashMap<>(); + Map unresolvedChanges = new LinkedHashMap<>(); for (Map.Entry 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> 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 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 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 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> 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 库存操作, * 覆盖远程成功但本地提交失败的窗口。 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java index cd865a940..09b96beaa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java @@ -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 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 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 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(); + } + /** * 构造线上预定提交的每日菜谱订单。 *