Browse Source

feat(order) 取消退款:ProductOrder新增refundBeforeStatus/refundBeforeRefundStatus快照字段;AfterSalesService.create写快照,新增cancel/cancelByOrderId方法;AfterSalesController新增POST /api/shop/after-sales/cancel端点;DB迁移迁移472+schema.sql同步

Agnes Agent 1 day ago
parent
commit
47ba9ebb3

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3528,6 +3528,13 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 并发改同配置不互相覆盖;实体 ProductDimensionConfig 含 @Version 字段,schema.sql 已同步)
         ensureColumn("product_dimension_config", "version",
                 "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号(并发改同配置不互相覆盖)'");
+
+        // 迁移472: product_orders 添加退款快照列(refund_before_status / refund_before_refund_status),
+        // 供售后申请取消时回滚订单至原状态。
+        ensureColumn("product_orders", "refund_before_status",
+                "VARCHAR(32) DEFAULT NULL COMMENT '退款前订单状态(售后取消回滚用)'");
+        ensureColumn("product_orders", "refund_before_refund_status",
+                "TINYINT(1) DEFAULT 0 COMMENT '退款前退款状态(售后取消回滚用)'");
     }
 
     /**

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/shop/AfterSalesController.java

@@ -40,4 +40,18 @@ public class AfterSalesController {
         Long id = ParamUtils.getLong(params.get("id"));
         return afterSalesService.detail(id, userId);
     }
+
+    @PostMapping("/cancel")
+    public Result<String> cancel(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("userId") Long userId) {
+        Long requestId = ParamUtils.getLong(params.get("requestId"));
+        if (requestId != null && requestId > 0) {
+            return afterSalesService.cancel(requestId, userId);
+        }
+        Long orderId = ParamUtils.getLong(params.get("orderId"));
+        if (orderId != null && orderId > 0) {
+            return afterSalesService.cancelByOrderId(orderId, userId);
+        }
+        return Result.error("请提供 requestId 或 orderId");
+    }
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -47,6 +47,8 @@ public class ProductOrder implements Serializable {
     private String refundReason;       // 退款原因
     private Integer refundAmount;      // 退款金额(分)
     private Date refundTime;           // 退款时间
+    private String refundBeforeStatus;          // 退款前订单状态(售后取消回滚用)
+    private Integer refundBeforeRefundStatus;   // 退款前退款状态(售后取消回滚用)
     // 供应商销售字段
     private Long supplySystemId;       // 供应商体系ID
     private Long supplierId;           // 供应商ID

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java

@@ -52,6 +52,9 @@ public class AfterSalesService {
         request.setUpdatedAt(new Date());
         afterSalesRequestMapper.insert(request);
 
+        // 写快照:在把订单置为 refunding 之前保存当前状态,供后续取消时回滚。
+        order.setRefundBeforeStatus(order.getStatus());
+        order.setRefundBeforeRefundStatus(order.getRefundStatus());
         order.setStatus("refunding");
         order.setRefundStatus(1);
         order.setRefundReason(request.getReason());
@@ -139,4 +142,58 @@ public class AfterSalesService {
         afterSalesRequestMapper.updateById(request);
         return Result.success("已完成");
     }
+
+    /**
+     * 按售后申请单ID取消退款申请(pending 态才可取消)。
+     */
+    public Result<String> cancel(Long requestId, Long userId) {
+        AfterSalesRequest request = afterSalesRequestMapper.selectById(requestId);
+        if (request == null) {
+            return Result.error("售后申请不存在");
+        }
+        if (!request.getUserId().equals(userId)) {
+            return Result.error("无权操作此售后申请");
+        }
+        if (!"pending".equals(request.getStatus())) {
+            return Result.error("后台已处理,无法取消");
+        }
+        ProductOrder order = productOrderMapper.selectById(request.getOrderId());
+        if (order == null) {
+            return Result.error("订单不存在");
+        }
+        if (!"refunding".equals(order.getStatus()) || order.getRefundStatus() == null || order.getRefundStatus() != 1) {
+            return Result.error("当前状态不可取消");
+        }
+        // 售后单置 cancelled
+        request.setStatus("cancelled");
+        request.setUpdatedAt(new Date());
+        afterSalesRequestMapper.updateById(request);
+        // 订单回滚
+        order.setStatus(order.getRefundBeforeStatus() != null ? order.getRefundBeforeStatus() : "paid");
+        order.setRefundStatus(order.getRefundBeforeRefundStatus() != null ? order.getRefundBeforeRefundStatus() : 0);
+        order.setRefundReason(null);
+        order.setRefundAmount(null);
+        order.setRefundTime(null);
+        order.setUpdatedAt(new Date());
+        productOrderMapper.updateById(order);
+        log.info("取消售后申请成功: requestId={}, orderId={}, 新订单状态={}", request.getId(), order.getId(), order.getStatus());
+        return Result.success("已取消退款申请,订单已恢复");
+    }
+
+    /**
+     * 按订单ID取消该订单最新的 pending 售后申请。
+     */
+    public Result<String> cancelByOrderId(Long orderId, Long userId) {
+        LambdaQueryWrapper<AfterSalesRequest> wrapper = new LambdaQueryWrapper<AfterSalesRequest>()
+                .eq(AfterSalesRequest::getOrderId, orderId)
+                .eq(AfterSalesRequest::getUserId, userId)
+                .eq(AfterSalesRequest::getStatus, "pending")
+                .orderByDesc(AfterSalesRequest::getCreatedAt)
+                .last("LIMIT 1");
+        AfterSalesRequest request = afterSalesRequestMapper.selectOne(wrapper);
+        if (request == null) {
+            return Result.error("未找到待处理的退款申请");
+        }
+        return cancel(request.getId(), userId);
+    }
 }

+ 2 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1356,6 +1356,8 @@ audit_reason TEXT COMMENT '审核拒绝原因',
     refund_reason VARCHAR(255) DEFAULT NULL COMMENT '退款原因',
     refund_amount INT DEFAULT NULL COMMENT '退款金额(分)',
     refund_time DATETIME DEFAULT NULL COMMENT '退款时间',
+    refund_before_status VARCHAR(32) DEFAULT NULL COMMENT '退款前订单状态(售后取消回滚用)',
+    refund_before_refund_status TINYINT(1) DEFAULT 0 COMMENT '退款前退款状态(售后取消回滚用)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     special_deal_id BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)',

+ 39 - 0
cfc-frontend/pages/shop/after-sales/after-sales.vue

@@ -46,6 +46,9 @@
               <text class="info-value">{{ formatTime(item.createdAt) }}</text>
             </view>
           </view>
+          <view v-if="item.status === 'pending'" class="card-actions" @click.stop="onCancelAfterSales(item)">
+            <text class="card-action-text">取消退款</text>
+          </view>
           <text class="card-arrow">›</text>
         </view>
       </view>
@@ -60,6 +63,7 @@
 import config from '@/config.js'
 import { parseDate } from '@/utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
+import { cancelRefund } from '@/utils/api.js'
 
 export default {
   mixins: [fontScaleMixin],
@@ -154,6 +158,27 @@ export default {
       var month = String(d.getMonth() + 1).padStart(2, '0')
       var day = String(d.getDate()).padStart(2, '0')
       return year + '-' + month + '-' + day
+    },
+    onCancelAfterSales(item) {
+      var self = this
+      uni.showModal({
+        title: '确认取消退款申请',
+        content: '确定取消该退款申请吗?将恢复订单原状态',
+        success: function(res) {
+          if (res.confirm) {
+            cancelRefund({ requestId: item.id }).then(function(resp) {
+              if (resp.code === 200) {
+                uni.showToast({ title: '已取消', icon: 'success' })
+                self.loadList()
+              } else {
+                uni.showToast({ title: resp.message || '取消失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络错误', icon: 'none' })
+            })
+          }
+        }
+      })
     }
   }
 }
@@ -283,4 +308,18 @@ export default {
   font-size: calc(24rpx * var(--font-scale, 1));
   color: $facade-text-muted;
 }
+.card-actions {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 12rpx;
+  border-top: 1rpx solid $facade-fill-2;
+  margin-top: 8rpx;
+}
+.card-action-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $status-danger-2;
+  padding: 4rpx 16rpx;
+  border: 1rpx solid $status-danger-2;
+  border-radius: 20rpx;
+}
 </style>

+ 66 - 13
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -237,16 +237,23 @@
       <!-- Coupon Section -->
       <view v-if="!isSpecialCouponDisabled" class="section coupon-section" @click="openCouponPicker">
         <view class="info-row">
-          <text class="info-label">优惠券</text>
+          <text class="info-label">
+            优惠券
+            <text v-if="!selectedCoupon && ownedCoupons.length > 0" class="coupon-count-hint">({{ ownedCoupons.length }}张)</text>
+          </text>
           <view class="coupon-selected" v-if="selectedCoupon">
             <text class="coupon-discount">-{{ formatPriceWithSymbol(couponDiscount) }}</text>
             <text class="coupon-name-tag">{{ selectedCoupon.name }}</text>
           </view>
           <view class="coupon-placeholder" v-else>
-            <text class="placeholder-text">选择优惠券</text>
+            <text class="placeholder-text" :class="{ 'placeholder-available': ownedCoupons.length > 0 }">
+              {{ ownedCoupons.length > 0 ? '有 ' + ownedCoupons.length + ' 张优惠券可抵,点击选择' : '选择优惠券' }}
+            </text>
           </view>
           <text class="address-arrow">›</text>
         </view>
+        <text v-if="_autoSelected && !selectedCoupon && ownedCoupons.length === 0" class="coupon-auto-hint">已按最优自动选择</text>
+        <text v-if="selectedCoupon" class="coupon-auto-hint">已按最优自动选择</text>
       </view>
       <view v-if="!isMember" class="section member-coupon-hint">
         <view class="member-coupon-hint-content">
@@ -510,6 +517,10 @@ export default {
       selectedCoupon: null,
       // 「我的优惠券」页跳转过来的券实例ID(from=coupon),用于自动选中
       _autoCouponId: null,
+      // 本次会话是否已自动选过最优券(避免重复触发)
+      _autoSelected: false,
+      // 本次会话用户是否手动取消过券(取消后不再自动选)
+      _couponDismissed: false,
       couponDiscount: 0,        // 服务端 /api/coupon/apply 返回的抵扣金额(分),前端只展示
       couponApplying: false,
       showCouponPicker: false,
@@ -721,20 +732,44 @@ export default {
       }
     },
     autoSelectCoupon() {
-      // 自动选中「我的优惠券」页透传的券(仅 from=coupon 场景,couponId=券实例ID):
-      // 在 ownedCoupons(已按商品/金额过滤)中匹配,命中则选中并触发服务端重算折扣;
-      // 未命中(minSpend 不满足 / 券已失效 / 券被过滤)静默忽略,不报错、不强制。
-      if (!this._autoCouponId) return
-      var matched = null
+      // 优先处理 from=coupon 深链场景:按 couponId 匹配选中的券。
+      if (this._autoCouponId) {
+        var matched = null
+        var list = this.ownedCoupons || []
+        for (var i = 0; i < list.length; i++) {
+          if (list[i] && parseInt(list[i].id) === this._autoCouponId) {
+            matched = list[i]
+            break
+          }
+        }
+        if (matched) {
+          this.selectCoupon(matched)
+          this._autoSelected = true
+          return
+        }
+        // 未命中则静默忽略,继续走下面的最优券逻辑
+      }
+      // 非深链场景:若用户本次尚未手动取消过券,则自动选最优券(value最大,同值取minSpend最小)。
+      if (this._couponDismissed || this._autoSelected) return
       var list = this.ownedCoupons || []
-      for (var i = 0; i < list.length; i++) {
-        if (list[i] && parseInt(list[i].id) === this._autoCouponId) {
-          matched = list[i]
-          break
+      if (list.length === 0) return
+      var best = null
+      for (var j = 0; j < list.length; j++) {
+        var c = list[j]
+        if (!c) continue
+        if (!best) {
+          best = c
+        } else {
+          var bestValue = best.value || 0
+          var curValue = c.value || 0
+          if (curValue > bestValue || (curValue === bestValue && (c.minSpend || 0) < (best.minSpend || 0))) {
+            best = c
+          }
         }
       }
-      if (matched) {
-        this.selectCoupon(matched)
+      if (best) {
+        this.selectCoupon(best)
+        this._autoSelected = true
       }
     },
     async loadExchangeableCoupons() {
@@ -829,6 +864,7 @@ export default {
       this.selectedCoupon = null
       this.couponDiscount = 0
       this.showCouponPicker = false
+      this._couponDismissed = true
     },
     // 优惠券抵扣统一由服务端计算(券类型/满减门槛/会员范围/折扣千分比),
     // 前端不再用 selectedCoupon.value 本地减额,保证展示与下单落库一致。
@@ -1494,6 +1530,23 @@ showOnlineSlotPicker: function() {
   font-size: calc(26rpx * var(--font-scale, 1));
   color: $facade-text-ghost;
 }
+.placeholder-available {
+  color: $brand-primary;
+  font-weight: 500;
+}
+.coupon-count-hint {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  margin-left: 8rpx;
+  font-weight: 500;
+}
+.coupon-auto-hint {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  margin-top: 8rpx;
+  opacity: 0.8;
+}
 
 .total-original {
   font-size: calc(22rpx * var(--font-scale, 1));

+ 47 - 6
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -16,8 +16,6 @@
         <text v-if="order.refundStatus === 1" class="status-hint">退款申请处理中,请耐心等待</text>
         <text v-if="order.refundStatus === 2" class="status-hint">退款已完成,金额将原路返回</text>
       </view>
-
-      <view class="section">
         <text class="section-title">订单信息</text>
         <view class="info-row">
           <text class="info-label">订单编号</text>
@@ -136,13 +134,14 @@
       <button v-if="order.status === 'shipped'" class="btn-confirm-receive" @click="onConfirmReceive">确认收货</button>
       <button v-if="order.status === 'paid' || order.status === 'shipped' || order.status === 'pending_receipt'" class="btn-after-sales" @click="onApplyAfterSales">申请售后</button>
       <button v-if="order.status === 'completed'" class="btn-buy-again" @click="onBuyAgain">再次购买</button>
+      <button v-if="order.refundStatus === 1 && order.status === 'refunding'" class="btn-cancel-refund" @click="onCancelRefund">取消退款申请</button>
       <button v-if="order.status === 'completed' || order.status === 'refunding' || order.status === 'refunded'" class="btn-after-sales" @click="onAfterSales">售后记录</button>
     </view>
   </view>
 </template>
 
 <script>
-import { productOrderDetail, productOrderConfirm, productOrderCancel, productOrderVendorConfirm } from '@/utils/api.js'
+import { productOrderDetail, productOrderConfirm, productOrderCancel, productOrderVendorConfirm, cancelRefund } from '@/utils/api.js'
 import config from '@/config.js'
 import { parseDate } from '@/utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -221,9 +220,38 @@ export default {
       })
     },
     onBuyAgain() {
-      if (this.order.items && this.order.items.length > 0) {
-        uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + this.order.items[0].productId })
-      }
+      var unitPrice = this.order.unitPrice || this.order.price || 0
+      var quantity = this.order.quantity || 1
+      var items = [{
+        productId: this.order.productId,
+        productName: this.order.productName,
+        coverImage: this.order.coverImage,
+        unitPrice: unitPrice,
+        quantity: quantity
+      }]
+      uni.setStorageSync('checkoutItems', items)
+      uni.navigateTo({ url: '/pages/shop/checkout/checkout' })
+    },
+    onCancelRefund() {
+      var self = this
+      uni.showModal({
+        title: '确认取消退款申请',
+        content: '确定取消退款申请吗?将恢复订单原状态',
+        success: function(res) {
+          if (res.confirm) {
+            cancelRefund({ orderId: self.order.id }).then(function(resp) {
+              if (resp.code === 200) {
+                uni.showToast({ title: '已取消', icon: 'success' })
+                self.loadDetail()
+              } else {
+                uni.showToast({ title: resp.message || '取消失败', icon: 'none' })
+              }
+            }).catch(function(err) {
+              uni.showToast({ title: err.message || '网络错误', icon: 'none' })
+            })
+          }
+        }
+      })
     },
     onAfterSales() {
       uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
@@ -594,6 +622,19 @@ export default {
   border-radius: 24rpx;
   border: none;
 }
+.btn-cancel-refund {
+  background: $facade-fill-2;
+  color: $status-danger-2;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  padding: 0 40rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  border-radius: 24rpx;
+  border: none;
+}
+.btn-cancel-refund::after {
+  border: none;
+}
 .btn-buy-again::after {
   border: none;
 }

+ 3 - 0
cfc-frontend/utils/api.js

@@ -1758,6 +1758,9 @@ export const productOrderCancel = (params) => {
   return request('/api/product/order/cancel', 'POST', params)
 }
 
+// 取消退款申请(售后)
+export const cancelRefund = (data) => request('/api/shop/after-sales/cancel', 'POST', data || {})
+
 // 我的订单
 export const productOrderMy = (params) => {
   return request('/api/product/order/my', 'POST', params)