diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/report/CanteenReportBoxExcel.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/report/CanteenReportBoxExcel.java index 070e319dc..93531a68f 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/report/CanteenReportBoxExcel.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/report/CanteenReportBoxExcel.java @@ -1,41 +1,41 @@ -package com.bsd.pjcloud.canteen.dto.report; - -import lombok.Data; - -import java.math.BigDecimal; - -/** - * 包厢台账(汇总)行数据。 - *

- * 导入已改为 {@code CanteenReportBoxExcelBinder} 按规范化表头名称映射, - * 不再依赖 EasyExcel {@code @ExcelProperty}(多值 value 会被当成多级表头而非别名)。 - */ -@Data -public class CanteenReportBoxExcel { - - private String monthText; - - private String bizDate; - - private String costCenter; - - private String costCenterCode; - - private String empNo; - - private String empName; - - private BigDecimal foodAmount; - - private BigDecimal drinkAmount; - - private BigDecimal totalAmount; - - private String legalEntity; - - private String remark1; - - private String remark2; - - private String park; -} +package com.bsd.pjcloud.canteen.dto.report; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 包厢台账(汇总)行数据。 + *

+ * 导入已改为 {@code CanteenReportBoxExcelBinder} 按规范化表头名称映射, + * 不再依赖 EasyExcel {@code @ExcelProperty}(多值 value 会被当成多级表头而非别名)。 + */ +@Data +public class CanteenReportBoxExcel { + + private String monthText; + + private String bizDate; + + private String costCenter; + + private String costCenterCode; + + private String empNo; + + private String empName; + + private BigDecimal foodAmount; + + private BigDecimal drinkAmount; + + private BigDecimal totalAmount; + + private String legalEntity; + + private String remark1; + + private String remark2; + + private String park; +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java index 7bf377813..efc9b963d 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java @@ -45,7 +45,7 @@ class CanteenSpuServiceImplTest { valueOperations = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(valueOperations); CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null, - mock(CanteenMerchantScopeHelper.class)); + mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class)); ReflectionTestUtils.setField(target, "baseMapper", spuMapper); service = target; TransactionSynchronizationManager.initSynchronization(); 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 35e2a959b..04d410625 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 @@ -2222,10 +2222,7 @@ public class HardOrderServiceImpl extends ServiceImpl entry : required.entrySet()) { DailyMenuAppVO row = menuBySpu.get(entry.getKey()); - // 线上剩余:优先 remainOnlineStock;兼容旧字段 stock/onlineStock(历史把剩余写在配额字段) - int onlineRemain = row.getRemainOnlineStock() != null ? row.getRemainOnlineStock() - : (row.getStock() != null ? row.getStock() - : (row.getOnlineStock() == null ? 0 : row.getOnlineStock())); + int onlineRemain = dailyOnlineRemain(row); if (onlineRemain < entry.getValue()) { throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName()); } @@ -4647,6 +4644,9 @@ public class HardOrderServiceImpl extends ServiceImpl inventorySnapshot = new LinkedHashMap<>(); @@ -4655,8 +4655,10 @@ public class HardOrderServiceImpl extends ServiceImpl> menuResult = remoteDailyMenuService.getCanteenList( - mealDate.toString(), hourType, null, order.getMerchantId()); + // 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @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); @@ -4670,13 +4672,11 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, + /** 每日菜谱分池幂等变更:ONLINE 改写线上池销量,OFFLINE 改写线下池销量,两池互不影响。 */ + private void applyCanteenDailyChannelInventoryChange(String stockChannel, Map stockChanges, Map inventorySnapshot, String inventoryBizId, String operation, Long merchantId, LocalDate mealDate, Integer hourType) { CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); dto.setBizId(inventoryBizId); dto.setOperation(operation); - dto.setStockChannel("OFFLINE"); + dto.setStockChannel(stockChannel); dto.setMerchantId(merchantId); dto.setItems(stockChanges.entrySet().stream().map(entry -> { CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); @@ -4829,7 +4830,7 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, + private void registerCanteenDailyChannelInventoryRollback(String stockChannel, Map stockChanges, Map inventorySnapshot, String inventoryBizId, Long merchantId, LocalDate mealDate, Integer hourType) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { @@ -4844,16 +4845,30 @@ public class HardOrderServiceImpl extends ServiceImpl + * 直接调用五参 {@code fastenerInventory} 重载:四参重载经 getSelf() 需要 Spring 上下文, + * 纯单测环境无法提供;异常文案由全局 i18n 处理器解析,此处只断言错误码。 */ class HardOrderServiceImplInventoryTest { @@ -42,6 +50,9 @@ class HardOrderServiceImplInventoryTest { /** 食堂基础菜品远程服务,负责查询权威库存并执行幂等扣减。 */ private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class); + /** 每日菜谱远程服务,负责按日期餐段查询分池剩余。 */ + private final RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class); + /** 被测订单服务,仅执行真实库存方法,其他依赖不参与本组测试。 */ private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); @@ -52,6 +63,7 @@ class HardOrderServiceImplInventoryTest { void setUp() { ReflectionTestUtils.setField(service, "hardCarService", hardCarService); ReflectionTestUtils.setField(service, "remoteCanteenSpuService", remoteCanteenSpuService); + ReflectionTestUtils.setField(service, "remoteDailyMenuService", remoteDailyMenuService); TransactionSynchronizationManager.initSynchronization(); } @@ -75,9 +87,9 @@ class HardOrderServiceImplInventoryTest { when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); RuntimeException exception = assertThrows(RuntimeException.class, - () -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null)); + () -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null)); - assertEquals("测试菜品库存不足", exception.getMessage()); + assertEquals(ErrorCodes.HARD_PRODUCT_STOCK_LOW, exception.getMessage()); verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); } @@ -91,7 +103,7 @@ class HardOrderServiceImplInventoryTest { 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)); + assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); @@ -129,7 +141,7 @@ class HardOrderServiceImplInventoryTest { RuntimeException exception = assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod( service, "validateCanteenSpuOrderInventory", cashierOrder())); - assertEquals("测试菜品库存不足", exception.getMessage()); + assertEquals(ErrorCodes.HARD_PRODUCT_STOCK_LOW, exception.getMessage()); verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); } @@ -143,7 +155,7 @@ class HardOrderServiceImplInventoryTest { 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.NO, 500L)); + assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.NO, 500L, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); @@ -174,6 +186,74 @@ class HardOrderServiceImplInventoryTest { assertEquals(-2, request.getItems().get(0).getIncrCount()); } + /** + * 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池, + * 快照取线上剩余并携带日期餐段。 + */ + @Test + void reserveRefundRestoresDailyMenuOnlineStock() { + prepareDailyMenuCart(2); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of(dailyMenuRow(8, 4)))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + assertDoesNotThrow(() -> service.fastenerInventory(reserveDailyMenuOrder(), null, YesNoEnum.NO, 500L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("APPLY", request.getOperation()); + assertEquals("ONLINE", request.getStockChannel()); + assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId()); + assertEquals(300L, request.getMerchantId()); + 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 池,快照取线下剩余。 + */ + @Test + void cashierDailyMenuRefundRestoresOfflineStock() { + prepareDailyMenuCart(2); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of(dailyMenuRow(8, 4)))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + HardOrderEntity order = reserveDailyMenuOrder(); + order.setOrderType(HardOrderTypeEnum.BELOW.getType()); + assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.NO, 500L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + 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()); + } + + /** + * 验证线上预定重审重扣按线上剩余校验:线上不足即拒绝,即使线下剩余充足。 + */ + @Test + void reserveReoccupyRejectsInsufficientOnlineStock() { + prepareDailyMenuCart(2); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of(dailyMenuRow(1, 4)))); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.fastenerInventory(reserveDailyMenuOrder(), null, YesNoEnum.YES, 500L, + "HARD_AFTER_SALE_REVIEW_REOCCUPY:500:1")); + + assertEquals(ErrorCodes.HARD_PRODUCT_STOCK_LOW, exception.getMessage()); + verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); + } + /** * 准备机器收银购物车来源和商品数量。 * @@ -193,6 +273,44 @@ class HardOrderServiceImplInventoryTest { when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car)); } + /** + * 准备每日菜谱购物车来源(cartScene 与商品来源标记与服务端保存逻辑一致)和商品数量。 + * + * @param productCount 本次购买份数 + */ + private void prepareDailyMenuCart(int productCount) { + HardParentCarEntity.TakeOutSetting setting = new HardParentCarEntity.TakeOutSetting(); + setting.setCartScene("DAILY_MENU"); + HardParentCarEntity parentCar = HardParentCarEntity.builder() + .id(200L) + .productSource(HardProductSourceEnum.CANTEEN_DAILY_MENU.getType()) + .takeOutSettingJson(setting) + .build(); + HardCarEntity car = HardCarEntity.builder() + .parentCarId(200L) + .productId(11L) + .productCount(productCount) + .build(); + when(hardCarService.getParentCarById(200L)).thenReturn(parentCar); + when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car)); + } + + /** + * 构造指定线上/线下剩余的每日菜谱行。 + * + * @param onlineRemain 线上池剩余 + * @param offlineRemain 线下池剩余 + * @return 用于分池校验的菜谱行 + */ + private DailyMenuAppVO dailyMenuRow(int onlineRemain, int offlineRemain) { + DailyMenuAppVO row = new DailyMenuAppVO(); + row.setSpuId(11L); + row.setName("测试菜品"); + row.setRemainOnlineStock(onlineRemain); + row.setRemainOfflineStock(offlineRemain); + return row; + } + /** * 构造指定权威库存的当前商户基础菜品。 * @@ -223,4 +341,21 @@ class HardOrderServiceImplInventoryTest { .merchantId(300L) .build(); } + + /** + * 构造线上预定提交的每日菜谱订单。 + * + * @return 关联每日菜谱购物车、固定日期餐段和商户的 RESERVE 订单 + */ + private HardOrderEntity reserveDailyMenuOrder() { + return HardOrderEntity.builder() + .id(100L) + .sysType(HardTypeEnum.CANTEEN.getType()) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .parentCarId(200L) + .merchantId(300L) + .apptUseDate(LocalDate.of(2026, 9, 13)) + .hourType("2") + .build(); + } }