瀏覽代碼

feat: 已退款商品不可重复退款,前后端逐商品校验剩余可退数量

- RefundService 新增 getRefundedQuantityMap,统计每个 orderGoodsId
  的累计已退款数量
- createRefundRecord 逐商品校验退款数量不超过剩余可退数量
- OrderServiceImpl.getProductsFromDatabase 暴露 refundedQuantity
- 小程序 refund.vue:已全额退款商品半透明禁用,上限改为剩余数量
- 管理后台退款弹窗:已退商品禁用勾选,显示"已退X件"标签

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 周之前
父節點
當前提交
4b578700cc

+ 43 - 26
haha-admin-web/src/views/order/utils/hook.tsx

@@ -488,8 +488,12 @@ export function useOrder(tableRef: Ref) {
       refundState.orderDetail = data;
       if (data.products && data.products.length > 0) {
         data.products.forEach((p: any, i: number) => {
-          refundState.selectedProducts.push(i);
-          refundState.refundQuantities[i] = p.productNum || 1;
+          // 已全额退款的商品不预选
+          const refundedQty = p.refundedQuantity || 0;
+          if (refundedQty < (p.productNum || 1)) {
+            refundState.selectedProducts.push(i);
+            refundState.refundQuantities[i] = (p.productNum || 1) - refundedQty;
+          }
         });
       }
     } catch {
@@ -514,22 +518,28 @@ export function useOrder(tableRef: Ref) {
               <ElTable data={refundState.orderDetail?.products || []} border size="small" style="width: 100%">
                 <ElTableColumn width={55} align="center"
                   v-slots={{
-                    default: ({ $index }: any) => (
-                      <el-checkbox
-                        model-value={refundState.selectedProducts.includes($index)}
-                        onChange={(val: boolean) => {
-                          if (val) {
-                            refundState.selectedProducts.push($index);
-                            if (!refundState.refundQuantities[$index]) {
-                              refundState.refundQuantities[$index] = refundState.orderDetail.products[$index].productNum || 1;
+                    default: ({ $index, row: r }: any) => {
+                      const refundedQty = r.refundedQuantity || 0;
+                      const isFullyRefunded = refundedQty >= (r.productNum || 1);
+                      return (
+                        <el-checkbox
+                          model-value={refundState.selectedProducts.includes($index)}
+                          disabled={isFullyRefunded}
+                          onChange={(val: boolean) => {
+                            if (val) {
+                              refundState.selectedProducts.push($index);
+                              if (!refundState.refundQuantities[$index]) {
+                                const remaining = (r.productNum || 1) - refundedQty;
+                                refundState.refundQuantities[$index] = Math.max(1, remaining);
+                              }
+                            } else {
+                              const idx = refundState.selectedProducts.indexOf($index);
+                              if (idx > -1) refundState.selectedProducts.splice(idx, 1);
                             }
-                          } else {
-                            const idx = refundState.selectedProducts.indexOf($index);
-                            if (idx > -1) refundState.selectedProducts.splice(idx, 1);
-                          }
-                        }}
-                      />
-                    )
+                          }}
+                        />
+                      );
+                    }
                   }}
                 />
                 <ElTableColumn label="商品名称" min-width={160}>
@@ -551,7 +561,12 @@ export function useOrder(tableRef: Ref) {
                           ) : (
                             <div style="width: 40px; height: 40px; background: #f5f5f5; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #999; flex-shrink: 0;">暂无</div>
                           )}
-                          <span>{r.productName}</span>
+                          <div>
+                            <span>{r.productName}</span>
+                            {(r.refundedQuantity || 0) > 0 && (
+                              <span style="color: #fa9550; font-size: 12px; margin-left: 6px;">(已退{r.refundedQuantity}件)</span>
+                            )}
+                          </div>
                         </div>
                       )
                     }}
@@ -564,14 +579,16 @@ export function useOrder(tableRef: Ref) {
                 />
                 <ElTableColumn label="退款数量" width={160} align="center"
                   v-slots={{
-                    default: ({ $index, row: r }: any) => (
-                      refundState.selectedProducts.includes($index) ? (
-                        <el-input-number
-                          model-value={refundState.refundQuantities[$index] || 1}
-                          min={1}
-                          max={r.productNum || 1}
-                          size="small"
-                          controls-position="right"
+                    default: ({ $index, row: r }: any) => {
+                      const maxRefundable = (r.productNum || 1) - (r.refundedQuantity || 0);
+                      return (
+                        refundState.selectedProducts.includes($index) ? (
+                          <el-input-number
+                            model-value={refundState.refundQuantities[$index] || 1}
+                            min={1}
+                            max={maxRefundable}
+                            size="small"
+                            controls-position="right"
                           style="width: 120px"
                           onUpdate:modelValue={(val: number) => { refundState.refundQuantities[$index] = val; }}
                         />

+ 1 - 0
haha-admin-web/src/views/order/utils/types.ts

@@ -41,6 +41,7 @@ export interface OrderItemDetail {
   productName: string;
   pic?: string;
   productNum?: number;
+  refundedQuantity?: number; // 已退款数量
   money?: number;
   originPrice?: number;
   price?: number;

+ 1 - 0
haha-mp/src/api/order.ts

@@ -13,6 +13,7 @@ export interface OrderProduct {
   name: string;
   price: number;
   quantity: number; // 购买数量
+  refundedQuantity?: number; // 已退款数量
   refundQuantity?: number; // 退款数量(用户选择)
   image?: string;
   subtotal?: number;

+ 53 - 6
haha-mp/src/pages/refund/refund.vue

@@ -15,11 +15,17 @@
         <view
           v-for="(product, index) in orderInfo.products"
           :key="index"
-          :class="['product-item', { 'product-item-selected': selectedProducts.includes(index) }]"
+          :class="['product-item', {
+            'product-item-selected': selectedProducts.includes(index),
+            'product-item-refunded': isProductFullyRefunded(index)
+          }]"
         >
-          <view class="product-main" @click="toggleProductSelect(index)">
+          <view class="product-main" @click="isProductFullyRefunded(index) ? null : toggleProductSelect(index)">
             <view class="product-checkbox">
-              <view :class="['checkbox-icon', { checked: selectedProducts.includes(index) }]">
+              <view v-if="isProductFullyRefunded(index)" class="checkbox-icon refunded">
+                <text>✓</text>
+              </view>
+              <view v-else :class="['checkbox-icon', { checked: selectedProducts.includes(index) }]">
                 <text v-if="selectedProducts.includes(index)">✓</text>
               </view>
             </view>
@@ -34,7 +40,12 @@
               </view>
               <view class="product-meta">
                 <text class="product-price">¥{{ (product.price || 0).toFixed(2) }}</text>
-                <text class="product-quantity-label">购买数量:x{{ product.quantity }}</text>
+                <text class="product-quantity-label">
+                  购买:x{{ product.quantity }}
+                  <text v-if="(product.refundedQuantity || 0) > 0" class="refunded-tag">
+                    已退{{ product.refundedQuantity }}件
+                  </text>
+                </text>
               </view>
             </view>
           </view>
@@ -53,7 +64,7 @@
                   <text class="quantity-text">{{ product.refundQuantity || 1 }}</text>
                 </view>
                 <view
-                  :class="['control-btn', (product.refundQuantity || 1) < product.quantity ? '' : 'disabled']"
+                  :class="['control-btn', (product.refundQuantity || 1) < getRemainingQuantity(index) ? '' : 'disabled']"
                   @click.stop="increaseQuantity(index)"
                 >
                   <text>+</text>
@@ -280,7 +291,19 @@ const handleReasonChange = (e: any) => {
   }
 };
 
+// 获取剩余可退数量
+const getRemainingQuantity = (index: number): number => {
+  const product = orderInfo.value.products[index];
+  return (product.quantity || 0) - (product.refundedQuantity || 0);
+};
+
+// 商品是否已全额退款
+const isProductFullyRefunded = (index: number): boolean => {
+  return getRemainingQuantity(index) <= 0;
+};
+
 const toggleProductSelect = (index: number) => {
+  if (isProductFullyRefunded(index)) return;
   const selectedIndex = selectedProducts.value.indexOf(index);
   if (selectedIndex > -1) {
     orderInfo.value.products[index].refundQuantity = undefined;
@@ -311,7 +334,8 @@ const decreaseQuantity = (index: number) => {
 
 const increaseQuantity = (index: number) => {
   const product = orderInfo.value.products[index];
-  if ((product.refundQuantity || 1) < product.quantity) {
+  const maxQty = getRemainingQuantity(index);
+  if ((product.refundQuantity || 1) < maxQty) {
     if (!product.refundQuantity) {
       product.refundQuantity = 2;
     } else {
@@ -533,6 +557,23 @@ const submitRefund = async () => {
   background-color: #f6ffed;
 }
 
+.product-item-refunded {
+  opacity: 0.6;
+  background-color: $color-bg-secondary;
+
+  .checkbox-icon.refunded {
+    background-color: $color-success;
+    border-color: $color-success;
+    opacity: 0.5;
+
+    text {
+      color: #ffffff;
+      font-size: 26rpx;
+      font-weight: bold;
+    }
+  }
+}
+
 .product-item:active {
   background-color: $color-bg-secondary;
 }
@@ -657,6 +698,12 @@ const submitRefund = async () => {
   border-radius: 6rpx;
 }
 
+.refunded-tag {
+  font-size: 22rpx;
+  color: #fa9550;
+  margin-left: 8rpx;
+}
+
 /* 退款数量选择器 */
 .refund-quantity-selector {
   margin-top: 20rpx;

+ 6 - 0
haha-service/src/main/java/com/haha/service/RefundService.java

@@ -32,6 +32,12 @@ public interface RefundService extends IService<Refund> {
      */
     Refund getDetailById(Long refundId);
 
+    /**
+     * 查询订单的每个商品已退款数量(仅统计 REFUNDED 状态的退款)
+     * @return Map<orderGoodsId, 已退款总数量>
+     */
+    Map<Long, Integer> getRefundedQuantityMap(Long orderId);
+
     /**
      * 查询订单的所有退款记录
      */

+ 18 - 0
haha-service/src/main/java/com/haha/service/impl/OrderServiceImpl.java

@@ -30,6 +30,7 @@ import com.haha.mapper.ShopMapper;
 import com.haha.mapper.UserMapper;
 import com.haha.service.UserTagService;
 import com.haha.service.OrderGoodsService;
+import com.haha.service.RefundService;
 import com.haha.service.OrderService;
 import com.haha.service.InviteActivityService;
 import com.haha.service.payment.PaymentService;
@@ -76,6 +77,10 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
     @Lazy
     private PayScoreService payScoreService;
 
+    @Autowired
+    @Lazy
+    private RefundService refundService;
+
     @Autowired
     @Lazy
     private OrderGoodsService orderGoodsService;
@@ -897,6 +902,16 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
             return products;
         }
         try {
+            // 查询已退款数量
+            Map<Long, Integer> refundedQuantityMap = null;
+            try {
+                if (refundService != null) {
+                    refundedQuantityMap = refundService.getRefundedQuantityMap(orderId);
+                }
+            } catch (Exception e) {
+                log.warn("查询订单 {} 已退款商品数量失败: {}", orderId, e.getMessage());
+            }
+
             List<OrderItemVO> goodsList = orderGoodsService.getVOByOrderId(orderId);
             if (goodsList != null && !goodsList.isEmpty()) {
                 for (OrderItemVO goods : goodsList) {
@@ -912,6 +927,9 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
                     product.put("subtotal", goods.getPrice() != null && goods.getProductNum() != null
                         ? goods.getPrice().multiply(new BigDecimal(goods.getProductNum()))
                         : new BigDecimal(0));
+                    int refundedQty = refundedQuantityMap != null
+                            ? refundedQuantityMap.getOrDefault(goods.getId(), 0) : 0;
+                    product.put("refundedQuantity", refundedQty);
                     products.add(product);
                 }
             }

+ 54 - 1
haha-service/src/main/java/com/haha/service/impl/RefundServiceImpl.java

@@ -7,10 +7,12 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.haha.common.enums.RefundStatus;
 import com.haha.common.constant.PaymentConstants;
 import com.haha.entity.Order;
+import com.haha.entity.OrderGoods;
 import com.haha.entity.Refund;
 import com.haha.entity.RefundItem;
 import com.haha.mapper.RefundItemMapper;
 import com.haha.mapper.RefundMapper;
+import com.haha.service.OrderGoodsService;
 import com.haha.service.OrderService;
 import com.haha.service.RefundService;
 import com.haha.service.payment.PaymentService;
@@ -33,14 +35,17 @@ public class RefundServiceImpl extends ServiceImpl<RefundMapper, Refund> impleme
 
     private final RefundItemMapper refundItemMapper;
     private final OrderService orderService;
+    private final OrderGoodsService orderGoodsService;
 
     @Autowired
     @Lazy
     private PaymentService paymentService;
 
-    public RefundServiceImpl(RefundItemMapper refundItemMapper, OrderService orderService) {
+    public RefundServiceImpl(RefundItemMapper refundItemMapper, OrderService orderService,
+                             OrderGoodsService orderGoodsService) {
         this.refundItemMapper = refundItemMapper;
         this.orderService = orderService;
+        this.orderGoodsService = orderGoodsService;
     }
 
     @Override
@@ -82,6 +87,32 @@ public class RefundServiceImpl extends ServiceImpl<RefundMapper, Refund> impleme
             throw new RuntimeException("退款金额不能超过可退金额" + availableAmount + "元(实付" + paidAmount + "元,已退" + alreadyRefunded + "元)");
         }
 
+        // 逐商品校验退款数量不超过剩余可退数量
+        if (products != null && !products.isEmpty()) {
+            Map<Long, Integer> refundedQuantityMap = getRefundedQuantityMap(orderId);
+            for (Map<String, Object> p : products) {
+                Long orderGoodsId = p.get("orderGoodsId") != null
+                        ? Long.valueOf(p.get("orderGoodsId").toString()) : 0L;
+                int requestQty = p.get("quantity") != null
+                        ? Integer.parseInt(p.get("quantity").toString()) : 1;
+                int alreadyRefundedQty = refundedQuantityMap.getOrDefault(orderGoodsId, 0);
+
+                if (alreadyRefundedQty > 0 || refundedQuantityMap.containsKey(orderGoodsId)) {
+                    // 查询原始购买数量
+                    OrderGoods orderGoods = orderGoodsService.getById(orderGoodsId);
+                    int originalQty = orderGoods != null && orderGoods.getProductNum() != null
+                            ? orderGoods.getProductNum() : Integer.MAX_VALUE;
+                    int remainingQty = originalQty - alreadyRefundedQty;
+
+                    if (requestQty > remainingQty) {
+                        String productName = (String) p.getOrDefault("productName", "商品");
+                        throw new RuntimeException(productName + " 可退数量不足:已退" + alreadyRefundedQty
+                                + "件,最多可退" + remainingQty + "件");
+                    }
+                }
+            }
+        }
+
         // 创建退款记录
         Refund refund = new Refund();
         refund.setOrderId(orderId);
@@ -151,6 +182,28 @@ public class RefundServiceImpl extends ServiceImpl<RefundMapper, Refund> impleme
         return this.getById(refundId);
     }
 
+    @Override
+    public Map<Long, Integer> getRefundedQuantityMap(Long orderId) {
+        Map<Long, Integer> result = new java.util.HashMap<>();
+        // 查询该订单所有已退款成功的记录
+        List<Refund> refundedList = lambdaQuery()
+                .eq(Refund::getOrderId, orderId)
+                .eq(Refund::getStatus, RefundStatus.REFUNDED.getCode())
+                .list();
+        if (refundedList == null || refundedList.isEmpty()) {
+            return result;
+        }
+        for (Refund refund : refundedList) {
+            List<RefundItem> items = getRefundItems(refund.getId());
+            if (items != null) {
+                for (RefundItem item : items) {
+                    result.merge(item.getOrderGoodsId(), item.getQuantity(), Integer::sum);
+                }
+            }
+        }
+        return result;
+    }
+
     @Override
     public List<Refund> getByOrderId(Long orderId) {
         return lambdaQuery()