Просмотр исходного кода

feat(backend): 套餐下单仅扣套餐SKU库存(校验/扣减/回补,不扣子商品)

Xiaogang Liao 1 неделя назад
Родитель
Сommit
367e34618d

+ 34 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -743,6 +743,12 @@ public class ProductOrderService {
                     && product.getStock() < item.getQuantity()) {
                 return Result.error("库存不足: " + product.getName());
             }
+            // 套餐商品:校验套餐SKU库存(只扣套餐SKU,不扣子商品)
+            if ("bundle".equals(product.getProductType())
+                    && itemSku != null
+                    && !productSkuService.hasStock(itemSku.getId(), item.getQuantity())) {
+                return Result.error("套餐库存不足: " + product.getName());
+            }
             int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
             if (itemSku != null) {
                 if (itemSku.getLinkedProductId() != null) {
@@ -1023,6 +1029,19 @@ public class ProductOrderService {
             // 记录出库流水
             inventoryService.recordOutbound(order.getProductId(), null, order.getQuantity(),
                     "order", order.getId(), null, "订单支付:" + orderNo);
+        } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType()) && paidSku != null) {
+            // 套餐商品:只扣套餐SKU库存,不扣子商品库存
+            boolean stockOk = productSkuService.decreaseStock(paidSku.getId(), order.getQuantity());
+            if (!stockOk) {
+                log.error("套餐库存扣减失败: orderNo={}, skuId={}, quantity={}",
+                        orderNo, paidSku.getId(), order.getQuantity());
+                order.setRemark("套餐库存扣减失败");
+                order.setUpdatedAt(new Date());
+                orderMapper.updateById(order);
+                throw new RuntimeException("套餐库存不足");
+            }
+            inventoryService.recordOutbound(order.getProductId(), paidSku.getId(), order.getQuantity(),
+                    "order", order.getId(), null, "订单支付:" + orderNo);
         }
         order.setStatus("paid");
         order.setTransactionId(transactionId);
@@ -1216,7 +1235,11 @@ public class ProductOrderService {
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本,幂等 refId = 商品订单 ID)
         refundOrderPoints(order, "取消订单退还CF值:" + orderNo);
         // Restore stock on cancel
-        productService.increaseStock(order.getProductId(), order.getQuantity());
+        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+        } else {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+        }
         // 记录退款入库流水
         inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
                 "order", order.getId(), null, "取消订单:" + orderNo);
@@ -1664,7 +1687,11 @@ public class ProductOrderService {
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        productService.increaseStock(order.getProductId(), order.getQuantity());
+        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+        } else {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+        }
         inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
                 "order", order.getId(), adminId, "退款:" + orderNo);
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
@@ -1882,7 +1909,11 @@ public class ProductOrderService {
             return Result.error("该订单无法关闭");
         }
         if ("paid".equals(currentStatus) || "pending_receipt".equals(currentStatus)) {
-            productService.increaseStock(order.getProductId(), order.getQuantity());
+            if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+                productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+            } else {
+                productService.increaseStock(order.getProductId(), order.getQuantity());
+            }
         }
         order.setStatus("closed");
         order.setUpdatedAt(new Date());

+ 149 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOrderStockTest.java

@@ -0,0 +1,149 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CreateProductOrderDTO;
+import com.etotem.cfc.dto.OrderItemVO;
+import com.etotem.cfc.dto.ProductOrderDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.ProductOrderService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 套餐商品(product_type='bundle')下单库存走套餐SKU:校验/扣减/回补均只作用于套餐SKU,
+ * 不触碰商品级库存(product.stock)与子商品SKU库存(无任何子商品扣减代码)。
+ */
+class ComboOrderStockTest {
+
+    private ProductOrderService orderService;
+    private ProductOrderMapper orderMapper;
+    private ProductMapper productMapper;
+    private ProductSkuService productSkuService;
+    private ProductService productService;
+    private InventoryService inventoryService;
+
+    @BeforeEach
+    void setUp() {
+        orderService = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        productMapper = mock(ProductMapper.class);
+        productSkuService = mock(ProductSkuService.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(orderService, "productMapper", productMapper);
+        ReflectionTestUtils.setField(orderService, "productSkuService", productSkuService);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+    }
+
+    private Product bundleProduct(Long id, String name) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        p.setProductType("bundle");
+        p.setStatus("on_shelf");
+        return p;
+    }
+
+    private ProductSku combo(Long id, Long productId, int price) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setProductId(productId);
+        s.setPrice(price);
+        return s;
+    }
+
+    private ProductOrder bundleOrder(String orderNo) {
+        ProductOrder o = new ProductOrder();
+        o.setId(1L);
+        o.setOrderNo(orderNo);
+        o.setProductId(99L);
+        o.setProductType("bundle");
+        o.setSkuId(900L);
+        o.setQuantity(2);
+        o.setStatus("pending");
+        o.setBuyerId(100L);
+        o.setTotalAmount(30000);
+        o.setMoneyAmount(30000);
+        o.setPointsUsed(0);
+        return o;
+    }
+
+    /** 断言1:下单套餐商品 → 库存校验走套餐SKU(product_skus.stock),不足则拒绝 */
+    @Test
+    void createBundle_invalidatesComboSkuStock() {
+        OrderItemVO item = new OrderItemVO();
+        item.setProductId(99L);
+        item.setSkuId(900L);
+        item.setQuantity(2);
+        CreateProductOrderDTO dto = new CreateProductOrderDTO();
+        dto.setItems(Collections.singletonList(item));
+        when(productMapper.selectById(99L)).thenReturn(bundleProduct(99L, "套餐X"));
+        when(productSkuService.getById(900L)).thenReturn(combo(900L, 99L, 15000));
+        when(productSkuService.hasStock(900L, 2)).thenReturn(false);
+
+        Result<ProductOrderDTO> res = orderService.create(dto, 100L);
+
+        assertNotNull(res);
+        assertNotEquals(200, res.getCode());
+        assertTrue(res.getMessage() != null && res.getMessage().contains("套餐库存不足"));
+        verify(productSkuService).hasStock(900L, 2);   // 走套餐SKU库存校验
+    }
+
+    /** 断言2:支付成功只扣套餐SKU库存(不扣商品级库存、无子商品扣减) */
+    @Test
+    void paymentSuccess_deductsComboSkuStockOnly() {
+        ProductOrder order = bundleOrder("B20261101");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(productMapper.selectById(99L)).thenReturn(bundleProduct(99L, "套餐X"));
+        when(productSkuService.getById(900L)).thenReturn(combo(900L, 99L, 15000));
+        when(productSkuService.decreaseStock(900L, 2)).thenReturn(true);
+
+        Result<String> res = (Result<String>) ReflectionTestUtils.invokeMethod(
+                orderService, "doHandlePaymentSuccess", "B20261101", "TX123");
+
+        assertNotNull(res);
+        assertNotEquals(-1, res.getCode() == null ? -1 : res.getCode());
+        // 只扣套餐SKU一次,商品级库存不扣,子商品SKU不被触碰
+        verify(productSkuService, times(1)).decreaseStock(900L, 2);
+        verify(productService, never()).decreaseStock(anyLong(), any());
+        // 出库流水记到套餐SKU
+        verify(inventoryService).recordOutbound(99L, 900L, 2, "order", 1L, null, "订单支付:B20261101");
+    }
+
+    /** 断言3:取消/关闭回补只加套餐SKU库存 */
+    @Test
+    void adminClose_restoresComboSkuStockOnly() {
+        ProductOrder order = bundleOrder("B20261101");
+        order.setStatus("paid");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> res = orderService.adminClose("B20261101", 1L);
+
+        assertNotNull(res);
+        verify(productSkuService).increaseStock(900L, 2);   // 只加套餐SKU库存
+        verify(productService, never()).increaseStock(anyLong(), any());
+    }
+}