Prechádzať zdrojové kódy

feat: 结算确认步骤 — 待结算金额需财务确认后才转入站点可提现余额

- 结算时不再直接转钱,仅生成待结算记录
- 新增 confirmSettlement 接口,确认后转账 + 状态改为已结算
- 站点+周期幂等:已有结算记录(不论状态)的站点不再重复结算
- 前端新增"确认结算"按钮,仅待结算记录可见

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 15 hodín pred
rodič
commit
95e050d862

+ 5 - 0
admin-web-new/src/api/finance.ts

@@ -30,6 +30,11 @@ export const triggerSettlement = () => {
   return http.request<any>("post", "/finance/triggerSettlement");
 };
 
+/** 确认结算 */
+export const confirmSettlement = (id: number) => {
+  return http.request<any>("post", "/finance/confirmSettlement", { data: { id } });
+};
+
 /** 用户资金流水列表 */
 export const getWalletDetailList = (data?: object) => {
   return http.request<any>("get", "/finance/walletDetails", { params: data });

+ 25 - 3
admin-web-new/src/views/admin/finance/settlement.vue

@@ -1,9 +1,9 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getSettlementList, triggerSettlement } from "@/api/finance";
+import { getSettlementList, triggerSettlement, confirmSettlement } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
-import { ElMessage } from "element-plus";
+import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
 
 defineOptions({
@@ -43,7 +43,8 @@ const state = reactive({
       { label: "期末余额(元)", prop: "closingPendingBalance", width: 150 },
       { label: "状态", prop: "status", width: 100 },
       { label: "备注", prop: "remark", width: 180 },
-      { label: "创建时间", prop: "createTime", width: 180 }
+      { label: "创建时间", prop: "createTime", width: 180 },
+      { label: "操作", prop: "action", width: 100, fixed: "right" }
     ]
   },
   stationOptions: [] as Array<{ stationId: string; stationName: string }>,
@@ -104,6 +105,23 @@ const handleTriggerSettlement = () => {
     });
 };
 
+const handleConfirmSettlement = (row: any) => {
+  ElMessageBox.confirm(
+    `确认将站点「${row.stationName}」${row.settlementPeriod} 的结算金额 ¥${(row.settlementAmount / 100).toFixed(2)} 转入可提现余额?`,
+    "确认结算",
+    { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
+  ).then(() => {
+    confirmSettlement(row.id)
+      .then(() => {
+        ElMessage.success("结算已确认,金额已转入站点可提现余额");
+        loadData(true);
+      })
+      .catch(() => {
+        ElMessage.error("确认结算失败");
+      });
+  }).catch(() => {});
+};
+
 const handleSizeChange = (size: number) => {
   state.pageQuery.pageSize = size;
   loadData(true);
@@ -175,6 +193,10 @@ const formatMoney = (value: number) => {
                 {{ formatDict('Settlement.status', row.status) }}
               </el-tag>
             </template>
+            <template v-else-if="col.prop === 'action'">
+              <el-button v-if="row.status === 0" type="primary" size="small" @click="handleConfirmSettlement(row)">确认结算</el-button>
+              <span v-else style="color: #909399; font-size: 12px;">-</span>
+            </template>
             <template v-else-if="['openingPendingBalance', 'totalRecharge', 'totalRefund', 'totalCrossIncome', 'totalCrossExpend', 'platformFeeBase', 'platformFee', 'withdrawalFee', 'settlementAmount', 'closingPendingBalance'].includes(col.prop)">
               {{ formatMoney(row[col.prop]) }}
             </template>

+ 18 - 0
admin-web/src/views/admin/finance/settlement.vue

@@ -121,6 +121,10 @@
                 <span class="station-name">{{ row.stationName }}</span>
               </div>
             </template>
+            <template v-else-if="field.prop==='action'">
+              <el-button v-if="row.status===0" type="primary" size="small" @click="handleConfirmSettlement(row)">确认结算</el-button>
+              <span v-else style="color: #909399; font-size: 12px;">-</span>
+            </template>
             <template v-else-if="field.prop==='status'">
               <el-tag :type="getStatusType(row.status)" size="small">
                 {{ getStatusLabel(row.status) }}
@@ -187,6 +191,7 @@ const state = reactive({
     {label: '状态', width: 100, prop: 'status', resizable: true},
     {label: '备注', width: 180, prop: 'remark', resizable: true},
     {label: '创建时间', width: 180, prop: 'createTime', resizable: true},
+    {label: '操作', width: 100, prop: 'action', fixed: 'right', resizable: true},
   ],
 })
 
@@ -229,6 +234,19 @@ const handleTriggerSettlement = () => {
   })
 };
 
+const handleConfirmSettlement = (row: any) => {
+  Msg.confirm(`确认将站点「${row.stationName}」${row.settlementPeriod} 的结算金额 ¥${(row.settlementAmount / 100).toFixed(2)} 转入可提现余额?`, '确认结算').then(() => {
+    Msg.showLoading(`确认中...`);
+    $body(`finance/confirmSettlement`, { id: row.id }).then(() => {
+      Msg.message("结算已确认,金额已转入站点可提现余额");
+      Msg.hideLoading();
+      loadData(true);
+    }).catch(() => {
+      Msg.hideLoading();
+    })
+  }).catch(() => {});
+};
+
 const getStatusLabel = (status: number) => u.fmt.fmtDict(status, 'Settlement.status');
 const getStatusType = (status: number): any => u.fmt.fmtDictColor(status, 'Settlement.status');
 </script>

+ 11 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java

@@ -8,6 +8,7 @@ import com.kym.entity.queryParams.WalletDetailQueryParam;
 import com.kym.service.*;
 import com.kym.service.wechat.WxPayService;
 import jakarta.validation.Valid;
+import java.util.Map;
 import org.springframework.web.bind.annotation.*;
 
 /**
@@ -184,6 +185,16 @@ public class FinanceController {
         return R.success();
     }
 
+    /**
+     * 确认结算 — 将待结算金额转入站点可提现余额
+     */
+    @SysLog("确认结算")
+    @PostMapping("/confirmSettlement")
+    public R<?> confirmSettlement(@RequestBody Map<String, Long> params) {
+        settlementService.confirmSettlement(params.get("id"));
+        return R.success();
+    }
+
     /**
      * 平台账户信息
      */

+ 5 - 0
car-wash-service/src/main/java/com/kym/service/SettlementService.java

@@ -23,4 +23,9 @@ public interface SettlementService extends MyBaseService<SettlementRecord> {
      * 结算记录列表
      */
     PageBean<SettlementRecordVo> listSettlementRecords(SettlementQueryParam params);
+
+    /**
+     * 确认结算 — 将待结算金额转入站点可提现余额,状态改为已结算
+     */
+    void confirmSettlement(Long recordId);
 }

+ 50 - 36
car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java

@@ -70,13 +70,6 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
 
         log.info("开始执行结算,周期:{}", period);
 
-        // 幂等检查:如果本期已有结算记录则跳过
-        long settledCount = lambdaQuery().eq(SettlementRecord::getSettlementPeriod, period).count();
-        if (settledCount > 0) {
-            log.warn("本期 {} 已有 {} 条结算记录,跳过重复结算", period, settledCount);
-            return;
-        }
-
         // 汇总本期各站点分账数据
         var allRecords = splitRecordService.lambdaQuery()
                 .ge(SplitRecord::getCreateTime, periodStart)
@@ -99,6 +92,16 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
     }
 
     private void doStationSettlement(String stationId, String period, List<SplitRecord> allRecords) {
+        // 站点+周期幂等:已有结算记录(不论状态)则跳过
+        long existing = lambdaQuery()
+                .eq(SettlementRecord::getStationId, stationId)
+                .eq(SettlementRecord::getSettlementPeriod, period)
+                .count();
+        if (existing > 0) {
+            log.info("站点 {} 周期 {} 已有结算记录,跳过", stationId, period);
+            return;
+        }
+
         int totalRecharge = sumByType(allRecords, stationId, SplitRecord.TYPE_RECHARGE, true);
         int totalRefund = sumByType(allRecords, stationId, SplitRecord.TYPE_REFUND, false);
         int totalCrossExpend = sumByType(allRecords, stationId, SplitRecord.TYPE_CROSS_EXPEND, false);
@@ -108,25 +111,16 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
             return;
         }
 
-        // 读取上期期末余额作为本期期初
         int openingBalance = getOpeningBalance(stationId, period);
 
-        // 本期货物流转净额
         int netInflow = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome;
-
-        // 微信提现手续费 0.6% — 钱从微信商户转出时先扣除,仅对货物流转净额计算
         int withdrawalFee = netInflow > 0
                 ? new BigDecimal(netInflow).multiply(WITHDRAWAL_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue()
                 : 0;
-
-        // 扣掉微信手续费后加上期结转,才是实际可分配金额
         int afterWechat = openingBalance + netInflow - withdrawalFee;
-
-        // 平台服务费 10% — 基于微信扣完手续费后可分配的金额
         int platformFee = afterWechat > 0
                 ? new BigDecimal(afterWechat).multiply(PLATFORM_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue()
                 : 0;
-
         int settlementAmount = afterWechat - platformFee;
 
         SettlementRecord record = new SettlementRecord()
@@ -145,33 +139,17 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
             record.setSettlementAmount(settlementAmount)
                     .setClosingPendingBalance(0)
                     .setStatus(SettlementRecord.STATUS_待结算);
-
-            // 增加站点可提现金额
-            stationAccountService.lambdaUpdate()
-                    .setSql("available_balance = available_balance + {0}", settlementAmount)
-                    .eq(StationAccount::getStationId, stationId)
-                    .update();
-
-            save(record);
-
-            // 平台服务费收入记录
-            if (platformFee > 0) {
-                platformRevenueRecordService.saveRecord(record, stationId);
-                platformAccountService.addRevenue(platformFee);
-            }
-
-            log.info("站点 {} 结算成功,金额:{} 分,平台费:{} 分,提现手续费:{} 分",
-                    stationId, settlementAmount, platformFee, withdrawalFee);
         } else {
             record.setSettlementAmount(0)
                     .setClosingPendingBalance(settlementAmount)
                     .setStatus(SettlementRecord.STATUS_异常结算)
                     .setRemark("结算金额为负,结转至下期");
+        }
 
-            save(record);
+        save(record);
 
-            log.warn("站点 {} 异常结算,结转金额:{} 分", stationId, settlementAmount);
-        }
+        log.info("站点 {} 周期 {} 结算记录已生成,状态:{},金额:{} 分,平台费:{} 分,提现手续费:{} 分",
+                stationId, period, record.getStatus(), record.getSettlementAmount(), platformFee, withdrawalFee);
     }
 
     /**
@@ -216,4 +194,40 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
         }).toList();
         return new PageBean<>(voList);
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void confirmSettlement(Long recordId) {
+        var record = getById(recordId);
+        if (record == null) {
+            throw new IllegalArgumentException("结算记录不存在");
+        }
+        if (record.getStatus() == SettlementRecord.STATUS_已结算) {
+            throw new IllegalArgumentException("该记录已确认,不可重复操作");
+        }
+        if (record.getStatus() != SettlementRecord.STATUS_待结算) {
+            throw new IllegalArgumentException("仅待结算记录可确认");
+        }
+
+        // 转入站点可提现余额
+        stationAccountService.lambdaUpdate()
+                .setSql("available_balance = available_balance + {0}", record.getSettlementAmount())
+                .eq(StationAccount::getStationId, record.getStationId())
+                .update();
+
+        // 平台服务费收入记录
+        if (record.getPlatformFee() != null && record.getPlatformFee() > 0) {
+            platformRevenueRecordService.saveRecord(record, record.getStationId());
+            platformAccountService.addRevenue(record.getPlatformFee());
+        }
+
+        // 更新状态为已结算
+        lambdaUpdate()
+                .set(SettlementRecord::getStatus, SettlementRecord.STATUS_已结算)
+                .eq(SettlementRecord::getId, recordId)
+                .update();
+
+        log.info("结算确认完成,记录ID:{},站点:{},周期:{},金额:{} 分",
+                recordId, record.getStationId(), record.getSettlementPeriod(), record.getSettlementAmount());
+    }
 }