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

Merge feature/order-refund-coupon-improvements (订单售后与购买体验优化: 取消退款/再买一单/优惠券最优) into cfclub

Agnes Agent 1 день назад
Родитель
Сommit
360ed0336

+ 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)

+ 160 - 0
docs/design/2026-11-14-order-refund-coupon-improvements.md

@@ -0,0 +1,160 @@
+# 订单售后与购买体验改进(取消退款 / 再买一单 / 优惠券最优选择)
+
+- 日期:2026-11-14
+- 分支:`feature/order-refund-coupon-improvements`
+- worktree:`E:/tmp/family-cart`(严禁触碰主工作树 `E:/cfc`)
+- 唯一事实源:本文件
+
+## 背景(用户原始需求)
+
+1. **取消退款**:用户在小程序误点退款后,后台还未处理退款时应能取消退款。
+2. **再买一单**:用户在订单页应能通过「再买一单」直接重新购买(当前只跳商品详情页)。
+3. **优惠券最优选择**:当商品可使用优惠券时,应有比较明显的提示,或按最优规则自动帮用户选中。
+
+## 一、取消退款
+
+### 现状分析
+- `ProductOrder` 状态流转:`paid / shipped / pending_receipt` → 申请售后后 `status="refunding"`、`refundStatus=1`(申请中)。
+- `AfterSalesService.create()`:插入 `AfterSalesRequest(status="pending")`,同时把订单置为 `refunding` + `refundStatus=1`,写 `refundReason/refundAmount`。
+- **缺口**:① 没有取消路径(`AfterSalesRequest.status` 只有 pending→approved→completed / rejected,无 cancelled);② `ProductOrder` 没有保存退款前的订单状态 → 取消后无法回滚。
+
+### 改动方案
+
+#### 1) 数据库迁移(Flyway)
+新增 `cfc-backend/src/main/resources/db/migration/V254__add_refund_before_state_to_product_orders.sql`:
+
+```sql
+ALTER TABLE `product_orders`
+  ADD COLUMN `refund_before_status` varchar(32) DEFAULT NULL COMMENT '退款前订单状态(售后取消回滚用)' AFTER `refund_status`,
+  ADD COLUMN `refund_before_refund_status` tinyint(1) DEFAULT 0 COMMENT '退款前退款状态(售后取消回滚用)' AFTER `refund_before_status`;
+```
+
+#### 2) 实体 `ProductOrder.java`
+新增字段:
+- `private String refundBeforeStatus;`
+- `private Integer refundBeforeRefundStatus;`
+
+#### 3) `AfterSalesService.create()`:写快照
+在把订单置为 `refunding` 之前:
+```java
+order.setRefundBeforeStatus(order.getStatus());          // paid / shipped / pending_receipt
+order.setRefundBeforeRefundStatus(order.getRefundStatus()); // 通常为 0
+```
+
+#### 4) 新增 `AfterSalesService.cancel(...)`
+签名:`Result<String> cancel(Long requestId, Long userId)`(按售后申请单取消)或 `Result<String> cancelByOrderId(Long orderId, Long userId)`。
+
+推荐按 **requestId** 取消 + 前端把 requestId 传过来;若前端只有 orderId,则实现 `cancelByOrderId`。两者可同时提供,核心逻辑一致:
+
+```
+1. 校验售后单存在,且 request.userId == userId,否则「无权操作」;
+2. 校验 request.status == "pending",否则「后台已处理,无法取消(仅待审核的申请可取消)」;
+3. 校验订单 status == "refunding" 且 refundStatus == 1,否则返回「当前状态不可取消」;
+4. 售后单 status → "cancelled",更新 updatedAt;
+5. 订单回滚:
+   order.status  = order.getRefundBeforeStatus()  != null ? refundBeforeStatus : "paid" 兜底;
+   order.refundStatus = refundBeforeRefundStatus != null ? refundBeforeRefundStatus : 0;
+   清空 refundReason / refundAmount / refundTime;更新 updatedAt;
+6. log.info 记录取消售后单 + 订单回滚。
+```
+
+#### 5) `AfterSalesController` 新增端点
+- `POST /api/shop/after-sales/cancel`
+- 请求体:`{ "requestId": 123 }`(或 `{ "orderId": 456 }`,取决于 4 的选型)
+- 从登录态取 userId(与现有 create/list 一致)。
+- 返回 `Result<String>`:「已取消退款申请,订单已恢复」。
+
+#### 6) 前端
+##### `order-detail.vue`(必要)
+- 状态提示区:`order.refundStatus === 1` 时(现已有「退款申请处理中,请耐心等待」),在其下方增加按钮 **「取消退款申请」**(`refundStatus===1` 且 `order.status==='refunding'` 时显示)。
+- 点击 → 确认弹窗(`uni.showModal`:「确定取消退款申请吗?将恢复订单原状态」)→ 调 `cancelRefund(orderId)` API:
+  - 成功后 `uni.showToast('已取消')` → 重新拉取订单详情刷新;
+  - 失败(后台已处理)toast 返回错误信息。
+- 若后端实现按 requestId,则订单详情/售后列表需带出 `AfterSalesRequest.id`;更简单:前端用 orderId 调用 `cancelByOrderId`,后端查该订单最新 pending 售后单。
+
+##### `after-sales.vue`(建议,可选)
+- `status==='pending'` 的申请卡片增加「取消退款」按钮(复用同一接口)。
+
+## 二、再买一单
+
+### 现状分析
+- `order-detail.vue onBuyAgain()`:仅 `uni.navigateTo` 商品详情页(`/pages/discover-detail/product-detail/product-detail?id=...`),用户需再点购买 + 进结算,体验断链。
+- 结算页 `checkout.vue onLoad`:已支持从 storage `checkoutItems` 读商品列表(数组 `[{productId, productName, coverImage, unitPrice, quantity}]`),读后 `calcAmount()`。
+- 订单详情接口返回 `ProductOrderDTO.from(order)`,含 `productId / productName / coverImage / unitPrice / quantity`(实体 `ProductOrder` 有这些字段)。
+
+### 改动方案
+`order-detail.vue onBuyAgain()` 改为**直接进结算页**:
+
+```js
+onBuyAgain() {
+  // 构造结算项:与购物车进入结算的 items 结构一致
+  const items = [{
+    productId: this.order.productId,
+    productName: this.order.productName,
+    coverImage: this.order.coverImage,
+    unitPrice: this.order.unitPrice || this.order.price || 0,   // 分
+    quantity: this.order.quantity || 1
+  }]
+  uni.setStorageSync('checkoutItems', items)
+  uni.navigateTo({ url: '/pages/shop/checkout/checkout' })
+}
+```
+
+注意:
+- `checkoutItems` 是一次性消费的 storage(checkout onLoad 读取后不影响购物车数据源——购物车路径走的是另一通道,二者不冲突);
+- 若商品已下架(返回错误),保持现有跳详情页逻辑作为兜底(`try/catch` 或根据订单状态判断);
+- 按钮展示条件保持现状(`order.status === 'completed'` 显示「再次购买」)——如需对 `paid/shipped/pending_receipt` 也放开,可加判断,但**不在本次范围**,避免与「申请售后」歧义。
+
+## 三、优惠券自动选择 / 明显提示
+
+### 现状分析
+- `checkout.vue` 已有:`ownedCoupons` computed(按 商品类型/满减门槛/金额 过滤)、优惠券 picker、`selectCoupon()` → `applyCouponDiscount()`(服务端 `/api/coupon/apply` 重算抵扣,展示与落库一致)。
+- `autoSelectCoupon()`:**仅**在 `from=coupon&couponId=xxx` 深链场景生效,不覆盖「当前商品本来就可用多张券」的常规场景。
+
+### 改动方案(自动最优 + 明显提示,两者都做)
+
+`checkout.vue`:
+
+1. **自动选最优券**(面额最大优先,同额取门槛低者):
+   - 在 `loadCoupons()` 成功后调用一次,逻辑:
+     - `_autoCouponId` 深链优先(现有逻辑保留);
+     - 否则,若 `ownedCoupons` 非空且用户**本次未手动取消过券**(`data._couponDismissed !== true`),
+       从 `ownedCoupons` 中挑 `value` 最大(同 value 取 `minSpend` 最小)的一张,`this.selectCoupon(best)`;
+     - 自动选中后置标 `_autoSelected = true`,空窗期避免重复触发。
+   - 不侵入下单逻辑:折扣仍由服务端 `/api/coupon/apply` 计算,与现有一致。
+2. **明显提示条**(未选中券但存在可用券时):
+   - `coupon-section` 未选券(`!selectedCoupon`)且 `ownedCoupons.length > 0` 时:
+     - 若已自动选中 → 正常展示选中态(`-¥X` + 券名),并在券区域标题旁加小字 `已按最优自动选择`;
+     - 若未自动选(如用户手动取消过)→ placeholder 文案改为 `有 N 张优惠券可抵,点击选择`(突出可用券数),加醒目样式。
+3. **用户手动取消后不再自动选**:`removeCoupon()` 时置 `_couponDismissed = true`(本次结算会话内生效,页面关掉自然重置——`data` 属性即可)。
+
+## 四、改动文件清单(预期 ≤ 8 个)
+
+后端:
+1. `cfc-backend/src/main/resources/db/migration/V254__add_refund_before_state_to_product_orders.sql`(新增)
+2. `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`(+2 字段)
+3. `cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java`(create 快照 + cancel/cancelByOrderId)
+4. `cfc-backend/src/main/java/com/etotem/cfc/controller/shop/AfterSalesController.java`(+cancel 端点)
+
+前端:
+5. `cfc-frontend/pages/shop/order-detail/order-detail.vue`(取消退款按钮 + 再买一单直进结算)
+6. `cfc-frontend/pages/shop/after-sales/after-sales.vue`(可选:pending 卡片取消按钮)
+7. `cfc-frontend/api/shop.js`(或对应 api 文件,新增 cancelRefund 调用)
+8. `cfc-frontend/pages/shop/checkout/checkout.vue`(自动最优券 + 提示条)
+
+## 五、验证与纪律
+
+1. **只在本目录**(`E:/tmp/family-cart`)工作;git 只在本分支 `feature/order-refund-coupon-improvements`;禁对主工作树 `E:/cfc` 任何写操作(禁 reset --hard / checkout -- . / clean / stash / add -A)。
+2. 后端 `cfc-backend` 模块 **mvn clean compile 必须通过**(在 `E:/tmp/family-cart/cfc-backend` 下执行)。
+3. 前端改动后 `npx eslint <改动的文件>`(PowerShell 环境,不要用 `wc -l` 等 bash 命令;统计用 `Measure-Object -Line`)。
+4. 提交:精确 `git add <paths>` → `git diff --cached --name-only | Measure-Object -Line` 校验变更数 → `git commit`(消息前缀 `feat(order-refund)`/`feat(shop)`)→ `git push -u origin feature/order-refund-coupon-improvements`。
+5. 完成后最后一条回复逐条汇报:改的文件、每个文件改了什么、后端起服务/编译是否通过、前端 lint 结果、push 的 commit sha。
+6. 不要改业务无关代码;不要碰 `.tdd/` 与主工作树。
+
+## 六、验收清单(自查)
+
+- [ ] 误点退款:`refundStatus===1` 时点「取消退款申请」,售后单变 `cancelled`,订单恢复原 `status`(paid/shipped/pending_receipt)且 `refundStatus=0`,`refundReason/refundAmount/refundTime` 清空;
+- [ ] 后台已 approve/reject 后,用户取消接口返回「后台已处理,无法取消」;
+- [ ] 「再买一单」:completed 订单一键直达结算页,商品信息正确、价格可算、可正常提交;
+- [ ] 优惠券:有可用券时自动选中面额最大者,券区显示「已按最优自动选择」;手动取消后不再自动选且显示「有 N 张优惠券可抵」提示;深链 `from=coupon` 场景不受影响;
+- [ ] 后端编译通过;前端 lint 通过;commit 已 push。