From 4e1c65acfa0646021d81dccd3f63ab9585da8118 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Sun, 13 Sep 2026 17:22:38 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E5=BA=93=E5=AD=98=E9=80=80=E6=AC=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../dto/report/CanteenReportBoxExcel.java | 82 +++++-----
.../impl/CanteenSpuServiceImplTest.java | 2 +-
.../impl/order/HardOrderServiceImpl.java | 57 ++++---
.../HardOrderServiceImplInventoryTest.java | 147 +++++++++++++++++-
4 files changed, 219 insertions(+), 69 deletions(-)
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();
+ }
}