ソースを参照

feat(乐收赢): 订单页加退款按钮,列表与抽屉各一处

后端端点见上一个提交。这一批只动订单页与它的 api 封装。

- 列表操作列与详情抽屉底部各一个入口。抽屉那个不是重复:翻到这儿的人往往正是
  **看完明细才决定退**的,让他退出去再找那一行是多余的一步。
  抽屉传的是 `detail` 而不是打开时那一行 —— 明细是刚拉的,列表行可能已经旧了。
- **只在可退状态上出现**(`SUCCESS` / `PARTIAL_REFUNDED`,与后端
  `LsyRefundService.REFUNDABLE` 同一集合)。这是前端这一侧的**第二道、不是闸**
  (真正的闸在后端,开放 API 的调用方根本不经过这一页);它拦的是体验 ——
  给一个点了必然 400 的按钮,操作员会以为是系统坏了。
  漂移方向是安全的:后端加了新状态而这里没跟上 ⇒ 那一类单**看不到**按钮,不是点错。
- 按钮挂的是**独立**权限串 `module_payment:leshouying:order:refund`(`v-hasPerm`),
  与列表的 `:list` 不共用。
- 确认框里写了三件事,少一件这个按钮就是在误导操作员:①金额默认是全部可退余额,
  改小即部分退款;②**提交成功 ≠ 到账**(拿到的是「退款中」);③单位在这里是元,
  发给后端前转成分 —— 用 `Math.round(x * 100)` 而不是 `* 100` 取整,
  因为 `0.29 * 100` 在二进制浮点下是 28.999…。
- 不做 HTML 拼接:单号走文本插值,不用 `dangerouslyUseHTMLString`。
- 留在抽屉里退款成功后三块数据一起重拉(订单明细 / 通知日志 / 退款单),
  别让界面停在退款前的快照上。
- 成功提示照实说「已受理」,不写「退款成功」:这一步之后退款单停在「退款中」,
  钱还没回到顾客账上。
alphaH 2 週間 前
親
コミット
31aad565d5

+ 23 - 0
frontend/src/api/module_payment/collection.ts

@@ -57,6 +57,29 @@ export function getLsyOrderRefunds(outTradeNo: string) {
   });
   });
 }
 }
 
 
+/**
+ * 发起退款(**动钱**)。
+ *
+ * ★ 金额单位是**分**;`refundFee` 省略(或传 undefined)= 退剩余全额,剩余由**服务端**算
+ *   (总额 − max(退款台账, 订单快照))。页面不要自己算完再传:那个式子在服务层已经有一份,
+ *   两处漂移的后果是「页面上显示的数」与「真正退出去的钱」不是同一个。
+ * ★ **不传退款单号** —— 它由服务端生成(`RF` + 雪花)。开放 API 那条腿要调用方自带号,
+ *   管理端按钮前面坐着的是操作员:让他手打单号只会造出重复号,而重复号在这条腿上是钱的事。
+ * ★ 后端是这个端点的**独立**权限串 `module_payment:leshouying:order:refund`,
+ *   与订单列表的 `:list` 不共用(能查一笔单不等于能动这笔钱)。按钮上挂的是**那一个**串。
+ * ★ 业务前置校验(金额超余额 / 状态不可退 / 有退款在途)由后端转成 HTTP 200 + code 400,
+ *   经响应拦截器变成一次 error toast ⇒ 调用方 `catch` 里**不要重试**,照原样告诉操作员即可。
+ */
+export function refundLsyOrder(outTradeNo: string, refundFee?: number) {
+  return request({
+    url: `/payment/collection/order/${outTradeNo}/refund`,
+    method: "post",
+    // 键名 snake_case(见文件头 ★ 3)。留空时发 `{}` 而不是 `{refund_fee: null}`:
+    // 后者在 `@Positive` 下同样放行,但两种「没填」在服务端日志里就分不开了。
+    data: refundFee == null ? {} : { refund_fee: refundFee },
+  });
+}
+
 // ==================== 退款 ====================
 // ==================== 退款 ====================
 export function getLsyRefundPage(params: {
 export function getLsyRefundPage(params: {
   pageNo: number;
   pageNo: number;

+ 116 - 1
frontend/src/views/module_payment/collection/order/index.vue

@@ -73,9 +73,25 @@
           </template>
           </template>
         </el-table-column>
         </el-table-column>
         <el-table-column prop="created_time" label="创建时间" width="180" />
         <el-table-column prop="created_time" label="创建时间" width="180" />
-        <el-table-column label="操作" width="100" fixed="right">
+        <el-table-column label="操作" width="150" fixed="right">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-button link type="primary" @click="openDetail(row)">详情</el-button>
             <el-button link type="primary" @click="openDetail(row)">详情</el-button>
+            <!--
+              退款只在「可退」的状态上出现,判据与后端 LsyRefundService.REFUNDABLE 同一集合:
+              其余状态(未支付 / 已关单 / 已全额退款)点了必然是 400 ——
+              与其给一个永远失败的按钮,不如不给。
+              ★ 权限串是 refundLsyOrder 上方那段注释里说的**独立**串,不复用列表的 :list。
+            -->
+            <el-button
+              v-if="REFUNDABLE_STATUS.includes(row.status)"
+              v-hasPerm="['module_payment:leshouying:order:refund']"
+              link
+              type="danger"
+              :loading="refundingNo === row.out_trade_no"
+              @click="handleRefund(row)"
+            >
+              退款
+            </el-button>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
       </el-table>
       </el-table>
@@ -200,6 +216,24 @@
           </template>
           </template>
         </el-table-column>
         </el-table-column>
       </el-table>
       </el-table>
+
+      <!--
+        抽屉里也放一个退款入口:翻到这儿的人往往正是**看完明细才决定退**的,
+        让他退出去再找那一行是多余的一步。判据与列表那一列完全相同(同一个集合、同一个串)。
+        ★ 传的是 `detail` 而不是打开抽屉时那一行:明细是刚拉的,列表行可能已经旧了。
+      -->
+      <template #footer>
+        <el-button
+          v-if="detail && REFUNDABLE_STATUS.includes(detail.status)"
+          v-hasPerm="['module_payment:leshouying:order:refund']"
+          type="danger"
+          :loading="refundingNo === detail.out_trade_no"
+          @click="handleRefund(detail)"
+        >
+          退款
+        </el-button>
+        <el-button @click="detailVisible = false">关闭</el-button>
+      </template>
     </el-drawer>
     </el-drawer>
   </div>
   </div>
 </template>
 </template>
@@ -216,11 +250,13 @@ defineOptions({
 });
 });
 
 
 import { onMounted, reactive, ref } from "vue";
 import { onMounted, reactive, ref } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
 import {
 import {
   getLsyOrderPage,
   getLsyOrderPage,
   getLsyOrderDetail,
   getLsyOrderDetail,
   getLsyNotifyLogs,
   getLsyNotifyLogs,
   getLsyOrderRefunds,
   getLsyOrderRefunds,
+  refundLsyOrder,
 } from "@/api/module_payment/collection";
 } from "@/api/module_payment/collection";
 
 
 const STATUS_OPTIONS = [
 const STATUS_OPTIONS = [
@@ -279,6 +315,16 @@ const REFUND_STATUS_LABEL: Record<string, string> = {
   FAIL: "退款失败",
   FAIL: "退款失败",
 };
 };
 
 
+/**
+ * 「可以发起退款」的订单状态 —— 与后端 `LsyRefundService.REFUNDABLE` **同一个集合**。
+ *
+ * ★ 这是**前端这一侧的第二道**,不是闸:真正的闸在后端(开放 API 的调用方根本不经过这一页)。
+ *   这里拦的是体验 —— 给一个点了必然 400 的按钮,操作员会以为是系统坏了。
+ * ★ 后端加了新状态(例如「已部分退款」的某种细分)而这里没跟上的后果是:
+ *   那一类单**看不到退款按钮**(不是点错),所以漂移方向是安全的。
+ */
+const REFUNDABLE_STATUS = ["SUCCESS", "PARTIAL_REFUNDED"];
+
 /** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
 /** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
 const query = reactive({
 const query = reactive({
   pageNo: 1,
   pageNo: 1,
@@ -294,6 +340,8 @@ const detailVisible = ref(false);
 const detail = ref<any>(null);
 const detail = ref<any>(null);
 const notifyLogs = ref<any[]>([]);
 const notifyLogs = ref<any[]>([]);
 const refunds = ref<any[]>([]);
 const refunds = ref<any[]>([]);
+/** 正在退款的那一单的**订单号**(不是 id)—— 列表行与抽屉用的是同一个键,两处的转圈才对得上。 */
+const refundingNo = ref<string | undefined>();
 
 
 function sceneLabel(v: string) {
 function sceneLabel(v: string) {
   return SCENE_LABEL[v] || v;
   return SCENE_LABEL[v] || v;
@@ -394,5 +442,72 @@ async function openDetail(row: any) {
   detailVisible.value = true;
   detailVisible.value = true;
 }
 }
 
 
+/**
+ * 发起退款(**动钱**)。
+ *
+ * 确认框里三件事必须写出来,少一件这个按钮就是在误导操作员:
+ * ① 金额默认是**全部可退余额**(改小即部分退款)—— 单位在这里是元,发给后端前转成分;
+ * ② 提交成功 ≠ 到账:拿到的是「退款中」,到账以状态变成「退款成功」为准(提示语照此写);
+ * ③ 权限是一枚**独立的**串(见 `refundLsyOrder` 的注释),没有它的人看不到这个按钮。
+ *
+ * 不做 HTML 拼接:单号走文本插值,不用 dangerouslyUseHTMLString。
+ * ★ `row` 既可能是列表行、也可能是抽屉里的 `detail` —— 两者都是订单实体,
+ *   四个字段(out_trade_no / total_amount / refunded_amount / status)都在。
+ */
+async function handleRefund(row: any) {
+  // 可退余额在这里只用于**预填**与「有没有得退」的早退。真正发出去的数是人填的那个,
+  // 而真正的余额校验在后端(它对台账与快照取 max,比这里的算式更严)。
+  const remainingFen = (row?.total_amount || 0) - (row?.refunded_amount || 0);
+  if (remainingFen <= 0) {
+    ElMessage.warning("这单没有可退余额(已退金额已达订单金额)");
+    return;
+  }
+
+  let input = "";
+  try {
+    const { value } = await ElMessageBox.prompt(
+      `订单 ${row.out_trade_no}:共 ${fen2yuan(row.total_amount)} 元,` +
+        `已退 ${fen2yuan(row.refunded_amount)} 元,可退 ${fen2yuan(remainingFen)} 元。` +
+        "金额默认是全部可退余额,改成更小的数是部分退款;" +
+        "提交成功只代表上游已受理,到账以退款单状态变为「退款成功」为准。",
+      "确认退款",
+      {
+        confirmButtonText: "确认退款",
+        cancelButtonText: "取消",
+        inputValue: (remainingFen / 100).toFixed(2),
+        inputPattern: /^\d+(\.\d{1,2})?$/,
+        inputErrorMessage: "请填写金额,最多两位小数",
+      }
+    );
+    input = value ?? "";
+  } catch {
+    return; // 取消(ElMessageBox 以 "cancel" reject),不发请求
+  }
+
+  // 元 → 分。`Math.round` 而不是 `* 100` 取整:0.29 * 100 在二进制浮点下是 28.999…。
+  const fen = Math.round(Number(input) * 100);
+  if (!Number.isFinite(fen) || fen <= 0) {
+    ElMessage.warning("退款金额必须大于 0");
+    return;
+  }
+
+  refundingNo.value = row.out_trade_no;
+  try {
+    const res: any = await refundLsyOrder(row.out_trade_no, fen);
+    const created = res.data.data;
+    // 照实说「已受理」:这一步之后退款单停在「退款中」,钱还没回到顾客账上。
+    ElMessage.success(
+      `退款已受理(${refundStatusLabel(created?.status)}),到账以状态变为「退款成功」为准`
+    );
+    if (detailVisible.value && detail.value?.out_trade_no === row.out_trade_no) {
+      // 抽屉正开着同一单:三块数据一起重拉,别让界面停在退款前的快照上
+      await openDetail({ out_trade_no: row.out_trade_no });
+    }
+    loadData();
+  } finally {
+    refundingNo.value = undefined;
+  }
+}
+
 onMounted(loadData);
 onMounted(loadData);
 </script>
 </script>