feat:库存退款

This commit is contained in:
dzc committed 2026-09-13 17:22:38 +08:00
1 parent 7fb7feea3f
commit 4e1c65acfa
4 files changed
+219 -69

No files matched your search

@@ -1,41 +1,41 @@
package com.bsd.pjcloud.canteen.dto.report;
import lombok.Data;
import java.math.BigDecimal;
/**
* 包厢台账(汇总)行数据。
* <p>
* 导入已改为 {@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;
/**
* 包厢台账(汇总)行数据。
* <p>
* 导入已改为 {@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;
}
@@ -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();
@@ -2222,10 +2222,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
for (Map.Entry<Long, Integer> 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<HardOrderMapper, HardOrder
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
boolean dailyMenu = isDailyMenuParentCar(parentCar);
// 每日菜谱分池路由:线上预定订单下单扣 ONLINE 池(updateDailyMenuReserveInventory),退款/重扣必须还同一池;
// 机器收银等其他类型下单走 OFFLINE 池。不看订单类型会把预约单的线上销量错补到线下池。
boolean onlineChannel = HardOrderTypeEnum.RESERVE.getType().equals(order.getOrderType());
LocalDate mealDate = order.getApptUseDate();
Integer hourType = parseHourType(order.getHourType());
Map<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
@@ -4655,8 +4655,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (mealDate == null || hourType == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
R<List<DailyMenuAppVO>> menuResult = remoteDailyMenuService.getCanteenList(
mealDate.toString(), hourType, null, order.getMerchantId());
// 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @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);
@@ -4670,13 +4672,11 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (row == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
int offlineRemain = row.getRemainOfflineStock() != null ? row.getRemainOfflineStock()
: (row.getStock() != null ? row.getStock()
: (row.getOfflineStock() != null ? row.getOfflineStock() : 0));
if (entry.getValue() < 0 && offlineRemain < Math.abs(entry.getValue())) {
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());
}
inventorySnapshot.put(entry.getKey(), offlineRemain);
inventorySnapshot.put(entry.getKey(), channelRemain);
}
}
else {
@@ -4718,10 +4718,11 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
? "HARD_DAILY_MENU_ORDER:" + order.getId()
: "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId;
if (dailyMenu) {
registerCanteenDailyOfflineInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId,
order.getMerchantId(), mealDate, hourType);
applyCanteenDailyOfflineInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY",
order.getMerchantId(), mealDate, hourType);
String stockChannel = onlineChannel ? "ONLINE" : "OFFLINE";
registerCanteenDailyChannelInventoryRollback(stockChannel, stockChanges, inventorySnapshot,
resolvedInventoryBizId, order.getMerchantId(), mealDate, hourType);
applyCanteenDailyChannelInventoryChange(stockChannel, stockChanges, inventorySnapshot,
resolvedInventoryBizId, "APPLY", order.getMerchantId(), mealDate, hourType);
}
else {
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
@@ -4799,14 +4800,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
/** 安卓机/线下每日菜谱:仍调原 updateSkuStockIdempotent,补 OFFLINE 通道参数。 */
private void applyCanteenDailyOfflineInventoryChange(Map<Long, Integer> stockChanges,
/** 每日菜谱分池幂等变更:ONLINE 改写线上池销量,OFFLINE 改写线下池销量,两池互不影响。 */
private void applyCanteenDailyChannelInventoryChange(String stockChannel, Map<Long, Integer> stockChanges,
Map<Long, Integer> 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<HardOrderMapper, HardOrder
}
}
private void registerCanteenDailyOfflineInventoryRollback(Map<Long, Integer> stockChanges,
private void registerCanteenDailyChannelInventoryRollback(String stockChannel, Map<Long, Integer> stockChanges,
Map<Long, Integer> inventorySnapshot, String inventoryBizId, Long merchantId, LocalDate mealDate,
Integer hourType) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
@@ -4844,16 +4845,30 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return;
}
try {
applyCanteenDailyOfflineInventoryChange(rollbackChanges, rollbackSnapshot, inventoryBizId,
applyCanteenDailyChannelInventoryChange(stockChannel, rollbackChanges, rollbackSnapshot, inventoryBizId,
"ROLLBACK", merchantId, mealDate, hourType);
}
catch (Exception compensationException) {
log.error("|{}|每日菜谱线下库存回滚失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException);
log.error("|{}|每日菜谱{}库存回滚失败,inventoryBizId:|{}|", TAG, stockChannel, inventoryBizId, compensationException);
}
}
});
}
/** 线上剩余:优先 remainOnlineStock;兼容旧字段 stock/onlineStock(历史把剩余写在配额字段)。 */
private static int dailyOnlineRemain(DailyMenuAppVO row) {
return row.getRemainOnlineStock() != null ? row.getRemainOnlineStock()
: (row.getStock() != null ? row.getStock()
: (row.getOnlineStock() == null ? 0 : row.getOnlineStock()));
}
/** 线下剩余:优先 remainOfflineStock;兼容旧字段 stock/offlineStock。 */
private static int dailyOfflineRemain(DailyMenuAppVO row) {
return row.getRemainOfflineStock() != null ? row.getRemainOfflineStock()
: (row.getStock() != null ? row.getStock()
: (row.getOfflineStock() != null ? row.getOfflineStock() : 0));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void fastenerInventory(HardOrderEntity order, YesNoEnum isReduce) {
@@ -1,9 +1,12 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
@@ -19,6 +22,7 @@ import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -32,7 +36,11 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 机器收银食堂订单库存分流测试,确保 BELOW 订单使用 {@code canteen_spu} 原子库存接口。
* 食堂订单库存分流测试:机器收银 BELOW 订单使用 {@code canteen_spu} 原子库存接口,
* 每日菜谱订单按订单类型路由分池(RESERVE 走 ONLINE,其他类型走 OFFLINE)。
* <p>
* 直接调用五参 {@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<CanteenSpuUpdateStockReqDTO> 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<CanteenSpuUpdateStockReqDTO> 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<CanteenSpuUpdateStockReqDTO> 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<CanteenSpuUpdateStockReqDTO> 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();
}
}