Przeglądaj źródła

平台与商家资金结算方案(V2)

skyline 2 tygodni temu
rodzic
commit
def38c06b3
33 zmienionych plików z 1562 dodań i 313 usunięć
  1. 10 0
      admin-web-new/src/api/finance.ts
  2. 9 0
      admin-web-new/src/router/modules/admin.ts
  3. 220 0
      admin-web-new/src/views/admin/finance/settlement.vue
  4. 0 13
      admin-web-new/src/views/admin/platform/rate-dialog.vue
  5. 1 2
      admin-web-new/src/views/admin/platform/rate.vue
  6. 4 7
      admin-web-new/src/views/admin/station/account.vue
  7. 15 0
      admin-web/src/router/route.ts
  8. 218 0
      admin-web/src/views/admin/finance/settlement.vue
  9. 0 10
      admin-web/src/views/admin/platform/rate/dialog.vue
  10. 0 1
      admin-web/src/views/admin/platform/rate/index.vue
  11. 4 7
      admin-web/src/views/admin/station/account/index.vue
  12. 22 7
      car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java
  13. 37 0
      car-wash-admin/src/main/java/com/kym/admin/jobs/SettlementJob.java
  14. 2 1
      car-wash-entity/src/main/java/com/kym/entity/PlatformFeeRate.java
  15. 90 0
      car-wash-entity/src/main/java/com/kym/entity/SettlementRecord.java
  16. 4 1
      car-wash-entity/src/main/java/com/kym/entity/SplitRecord.java
  17. 10 2
      car-wash-entity/src/main/java/com/kym/entity/StationAccount.java
  18. 2 1
      car-wash-entity/src/main/java/com/kym/entity/StationFeeRate.java
  19. 17 0
      car-wash-entity/src/main/java/com/kym/entity/queryParams/SettlementQueryParam.java
  20. 33 0
      car-wash-entity/src/main/java/com/kym/entity/vo/SettlementRecordVo.java
  21. 2 20
      car-wash-entity/src/main/java/com/kym/entity/vo/StationAccountVo.java
  22. 65 0
      car-wash-entity/src/main/resources/sql/v2_settlement.sql
  23. 13 0
      car-wash-mapper/src/main/java/com/kym/mapper/SettlementRecordMapper.java
  24. 21 0
      car-wash-mapper/src/main/resources/mappers/SettlementRecordMapper.xml
  25. 6 0
      car-wash-service/pom.xml
  26. 26 0
      car-wash-service/src/main/java/com/kym/service/SettlementService.java
  27. 22 201
      car-wash-service/src/main/java/com/kym/service/awoara/event/handle/OrderCloseEventHandler.java
  28. 181 0
      car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java
  29. 5 16
      car-wash-service/src/main/java/com/kym/service/impl/StationAccountServiceImpl.java
  30. 15 0
      car-wash-service/src/main/java/com/kym/service/impl/WithdrawnRecordServiceImpl.java
  31. 3 24
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java
  32. 270 0
      car-wash-service/src/test/java/com/kym/service/impl/SettlementServiceImplTest.java
  33. 235 0
      docs/结算方案-业务说明.md

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

@@ -19,3 +19,13 @@ export const getWithdrawList = (data?: object) => {
 export const getSplitRecordList = (data?: object) => {
   return http.request<any>("post", "/finance/splitRecords", { data });
 };
+
+/** 结算记录列表 */
+export const getSettlementList = (data?: object) => {
+  return http.request<any>("post", "/finance/settlementRecords", { data });
+};
+
+/** 手动触发结算 */
+export const triggerSettlement = () => {
+  return http.request<any>("post", "/finance/triggerSettlement");
+};

+ 9 - 0
admin-web-new/src/router/modules/admin.ts

@@ -121,6 +121,15 @@ export default {
             icon: "ri:exchange-dollar-line",
             title: "分账记录"
           }
+        },
+        {
+          path: "/admin/finance/settlement",
+          name: "AdminFinanceSettlement",
+          component: () => import("@/views/admin/finance/settlement.vue"),
+          meta: {
+            icon: "ri:calendar-check-line",
+            title: "结算记录"
+          }
         }
       ]
     },

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

@@ -0,0 +1,220 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref, nextTick } from "vue";
+import { getSettlementList, triggerSettlement } from "@/api/finance";
+import { getStationList } from "@/api/station";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage } from "element-plus";
+
+defineOptions({
+  name: "AdminFinanceSettlement"
+});
+
+const queryRef = ref();
+const tableRef = ref();
+
+const state = reactive({
+  formQuery: {
+    stationId: "",
+    settlementPeriod: "",
+    status: ""
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false,
+    columns: [
+      { label: "站点名称", prop: "stationName", width: 180 },
+      { label: "结算周期", prop: "settlementPeriod", width: 120 },
+      { label: "期初余额(元)", prop: "openingPendingBalance", width: 150 },
+      { label: "总充值(元)", prop: "totalRecharge", width: 140 },
+      { label: "总退款(元)", prop: "totalRefund", width: 140 },
+      { label: "跨店收入(元)", prop: "totalCrossIncome", width: 140 },
+      { label: "跨店支出(元)", prop: "totalCrossExpend", width: 140 },
+      { label: "平台费基数(元)", prop: "platformFeeBase", width: 150 },
+      { label: "平台费(元)", prop: "platformFee", width: 130 },
+      { label: "结算金额(元)", prop: "settlementAmount", width: 150 },
+      { label: "期末余额(元)", prop: "closingPendingBalance", width: 150 },
+      { label: "状态", prop: "status", width: 100 },
+      { label: "备注", prop: "remark", width: 180 },
+      { label: "创建时间", prop: "createTime", width: 180 }
+    ]
+  },
+  stationOptions: [] as Array<{ stationId: string; stationName: string }>,
+  statusOptions: [
+    { label: "全部", value: "" },
+    { label: "待结算", value: "0" },
+    { label: "已结算", value: "1" },
+    { label: "异常结算", value: "2" }
+  ]
+});
+
+onMounted(() => {
+  loadStationOptions();
+  loadData();
+  nextTick(() => {
+    const bodyHeight = document.body.clientHeight;
+    const queryHeight = queryRef.value?.$el?.clientHeight || 0;
+    state.tableData.height = bodyHeight - queryHeight - 280;
+  });
+});
+
+const loadStationOptions = () => {
+  getStationList({ pageSize: 1000 }).then((res: any) => {
+    const { list } = res || {};
+    state.stationOptions = list || [];
+  });
+};
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) {
+    state.pageQuery.pageNum = 1;
+  }
+  state.tableData.loading = true;
+  getSettlementList({ ...state.formQuery, ...state.pageQuery })
+    .then((res: any) => {
+      const { list, total } = res || {};
+      state.tableData.data = list || [];
+      state.pageQuery.total = total || 0;
+    })
+    .catch(() => {
+      state.tableData.data = [];
+    })
+    .finally(() => {
+      state.tableData.loading = false;
+    });
+};
+
+const handleTriggerSettlement = () => {
+  const loading = ElMessage({ message: "结算执行中...", type: "info", duration: 0 });
+  triggerSettlement()
+    .then(() => {
+      loading.close();
+      ElMessage.success("结算已完成");
+      loadData(true);
+    })
+    .catch(() => {
+      loading.close();
+    });
+};
+
+const handleSizeChange = (size: number) => {
+  state.pageQuery.pageSize = size;
+  loadData(true);
+};
+
+const handleCurrentChange = (page: number) => {
+  state.pageQuery.pageNum = page;
+  loadData();
+};
+
+const handleSearch = () => {
+  loadData(true);
+};
+
+const handleReset = () => {
+  state.formQuery = { stationId: "", settlementPeriod: "", status: "" };
+  loadData(true);
+};
+
+const formatMoney = (value: number) => {
+  if (value === null || value === undefined) return "0.00";
+  return (value / 100).toFixed(2);
+};
+
+const getStatusLabel = (status: number) => {
+  const map: Record<number, string> = {
+    0: "待结算",
+    1: "已结算",
+    2: "异常结算"
+  };
+  return map[status] || String(status);
+};
+
+const getStatusType = (status: number): any => {
+  const map: Record<number, string> = {
+    0: "info",
+    1: "success",
+    2: "danger"
+  };
+  return map[status] || "info";
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <el-card shadow="hover">
+      <el-form ref="queryRef" :model="state.formQuery" inline class="search-form">
+        <el-form-item label="站点">
+          <el-select v-model="state.formQuery.stationId" placeholder="请选择站点" clearable filterable @change="handleSearch">
+            <el-option v-for="item in state.stationOptions" :key="item.stationId" :label="item.stationName" :value="item.stationId" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="结算周期">
+          <el-input v-model="state.formQuery.settlementPeriod" placeholder="如 2026-05" clearable @change="handleSearch" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="state.formQuery.status" placeholder="请选择状态" clearable @change="handleSearch">
+            <el-option v-for="item in state.statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="useRenderIcon('ri/search-line')" @click="handleSearch">查询</el-button>
+          <el-button :icon="useRenderIcon('ri/refresh-line')" @click="handleReset">重置</el-button>
+          <el-button type="warning" :icon="useRenderIcon('ri/money-cny-circle-line')" @click="handleTriggerSettlement">手动触发结算</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table ref="tableRef" v-loading="state.tableData.loading" :data="state.tableData.data" :height="state.tableData.height" border stripe>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
+        <el-table-column v-for="col in state.tableData.columns" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" show-overflow-tooltip>
+          <template #default="{ row }">
+            <template v-if="col.prop === 'status'">
+              <el-tag :type="getStatusType(row.status)" size="small">
+                {{ getStatusLabel(row.status) }}
+              </el-tag>
+            </template>
+            <template v-else-if="['openingPendingBalance', 'totalRecharge', 'totalRefund', 'totalCrossIncome', 'totalCrossExpend', 'platformFeeBase', 'platformFee', 'settlementAmount', 'closingPendingBalance'].includes(col.prop)">
+              {{ formatMoney(row[col.prop]) }}
+            </template>
+            <template v-else>
+              {{ row[col.prop] }}
+            </template>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="state.pageQuery.pageNum"
+          v-model:page-size="state.pageQuery.pageSize"
+          :total="state.pageQuery.total"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  padding: 15px;
+}
+.search-form {
+  margin-bottom: 15px;
+}
+.pagination-container {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 15px;
+}
+</style>

+ 0 - 13
admin-web-new/src/views/admin/platform/rate-dialog.vue

@@ -15,7 +15,6 @@ const state = reactive({
     id: null as number | null,
     name: "",
     feeRate: null as number | null,
-    frozenRatio: null as number | null,
     withdrawalFeeRate: null as number | null
   },
   rules: {
@@ -28,7 +27,6 @@ const resetForm = () => {
     id: null,
     name: "",
     feeRate: null,
-    frozenRatio: null,
     withdrawalFeeRate: null
   };
   formRef.value?.resetFields();
@@ -111,17 +109,6 @@ defineExpose({ open });
           class="w100"
         />
       </el-form-item>
-      <el-form-item label="充值冻结金额比例(0.3代表30%)" prop="frozenRatio">
-        <el-input-number
-          v-model="state.ruleForm.frozenRatio"
-          :min="0"
-          :max="1"
-          :step="0.1"
-          :precision="2"
-          placeholder="充值冻结金额比例"
-          class="w100"
-        />
-      </el-form-item>
       <el-form-item label="提现手续费率(0.006代表6‰)" prop="withdrawalFeeRate">
         <el-input-number
           v-model="state.ruleForm.withdrawalFeeRate"

+ 1 - 2
admin-web-new/src/views/admin/platform/rate.vue

@@ -28,7 +28,6 @@ const state = reactive({
     columns: [
       { label: "费率名称", prop: "name", width: 180 },
       { label: "平台费率", prop: "feeRate", width: 180 },
-      { label: "充值冻结金额比例", prop: "frozenRatio", width: 180 },
       { label: "提现手续费率", prop: "withdrawalFeeRate", width: 180 },
       { label: "创建时间", prop: "createTime", width: 180 },
       { label: "更新时间", prop: "updateTime", width: 180 }
@@ -167,7 +166,7 @@ const formatRate = (val: number) => {
             <template v-if="col.prop === 'name'">
               <span class="link" @click="handleView(row)">{{ row.name }}</span>
             </template>
-            <template v-else-if="['feeRate', 'frozenRatio', 'withdrawalFeeRate'].includes(col.prop)">
+            <template v-else-if="['feeRate', 'withdrawalFeeRate'].includes(col.prop)">
               {{ formatRate(row[col.prop]) }}
             </template>
             <template v-else>

+ 4 - 7
admin-web-new/src/views/admin/station/account.vue

@@ -26,12 +26,9 @@ const state = reactive({
     loading: false,
     columns: [
       { label: "站点名称", prop: "stationName", width: 200 },
-      { label: "可提现金额(元)", prop: "balance", width: 180 },
-      { label: "冻结金额(元)", prop: "frozenAmount", width: 180 },
-      { label: "充值未消费金额(元)", prop: "unusedAmount", width: 180 },
-      { label: "未消费可分账金额(元)", prop: "frozenAmountSplit", width: 200 },
-      { label: "已提现金额(元)", prop: "withdrawnAmount", width: 200 },
-      { label: "提现中金额(元)", prop: "withdrawnFrozenAmount", width: 200 },
+      { label: "可提现金额(元)", prop: "availableBalance", width: 180 },
+      { label: "已提现金额(元)", prop: "withdrawnAmount", width: 180 },
+      { label: "提现中金额(元)", prop: "withdrawnFrozenAmount", width: 180 },
       { label: "创建时间", prop: "createTime", width: 180 },
       { label: "更新时间", prop: "updateTime", width: 180 }
     ]
@@ -196,7 +193,7 @@ const formatMoney = (value: number) => {
         >
           <template #default="{ row }">
             <template
-              v-if="['balance', 'frozenAmount', 'unusedAmount', 'frozenAmountSplit', 'withdrawnAmount', 'withdrawnFrozenAmount'].includes(col.prop)"
+              v-if="['availableBalance', 'withdrawnAmount', 'withdrawnFrozenAmount'].includes(col.prop)"
             >
               {{ formatMoney(row[col.prop]) }}
             </template>

+ 15 - 0
admin-web/src/router/route.ts

@@ -241,6 +241,21 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                     perm: "withdrawnRecord.list",
                 },
             },
+            {
+                path: '/finance/settlement',
+                name: 'adminFinanceSettlement',
+                component: () => import('/@/views/admin/finance/settlement.vue'),
+                meta: {
+                    title: '结算记录',
+                    isLink: '',
+                    isHide: false,
+                    isKeepAlive: true,
+                    isAffix: false,
+                    isIframe: false,
+                    icon: 'ele-Calendar',
+                    perm: "settlement.list",
+                },
+            },
             {
                 path: '/financeSR',
                 name: 'adminSplitRecord',

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

@@ -0,0 +1,218 @@
+<style scoped lang="scss">
+.system-container {
+
+  :deep(.el-card__body) {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    flex: 1;
+    overflow: auto;
+
+    .el-table {
+      flex: 1;
+    }
+
+  }
+}
+
+.page-content {
+  margin-bottom: 20px;
+}
+
+.page-pager {
+  background-color: var(--el-color-white);
+  height: 24px;
+}
+</style>
+<template>
+  <div class="system-container layout-padding">
+    <el-card shadow="hover" class="layout-padding-auto">
+      <el-form
+          :model="state.formQuery"
+          ref="queryRef"
+          size="default" label-width="0px" class="mt5 mb5">
+        <ext-select
+            v-model="state.formQuery.stationId"
+            placeholder="站点"
+            url="washStation/list"
+            url-method="post"
+            label-key="stationName"
+            value-key="stationId"
+            data-key="list"
+            clearable
+            class="wd200 ml10"/>
+
+        <el-input
+            v-model="state.formQuery.settlementPeriod"
+            placeholder="结算周期 如 2026-05"
+            clearable
+            class="wd200 ml10"/>
+
+        <el-select
+            v-model="state.formQuery.status"
+            placeholder="状态"
+            clearable
+            class="wd150 ml10">
+          <el-option label="全部" value=""/>
+          <el-option label="待结算" value="0"/>
+          <el-option label="已结算" value="1"/>
+          <el-option label="异常结算" value="2"/>
+        </el-select>
+
+        <el-button class="ml10" plain size="default" type="success" @click="loadData(true)">
+          <SvgIcon name="ele-Search"/>
+          查询
+        </el-button>
+
+        <el-button class="ml10" plain size="default" type="warning" @click="handleTriggerSettlement">
+          <SvgIcon name="ele-Money"/>
+          手动触发结算
+        </el-button>
+      </el-form>
+
+      <el-table
+          border
+          stripe="stripe"
+          :height="state.tableData.height"
+          :data="state.tableData.data"
+          v-loading="state.tableData.loading">
+        <template #empty>
+          <el-empty></el-empty>
+        </template>
+        <el-table-column
+            v-for="field in state.columns"
+            :key="field.prop"
+            :label="field.label"
+            :column-key="field.prop"
+            :width="field.width"
+            :min-width="field.minWidth"
+            :fixed="field.fixed"
+            :sortable="field.sortable"
+            :show-overflow-tooltip="!field.fixed&&field.width>150">
+
+          <template #default="{row}">
+            <template v-if="field.prop==='status'">
+              <el-tag :type="getStatusType(row.status)" size="small">
+                {{ getStatusLabel(row.status) }}
+              </el-tag>
+            </template>
+            <template v-else-if="['openingPendingBalance','totalRecharge','totalRefund','totalCrossIncome','totalCrossExpend','platformFeeBase','platformFee','settlementAmount','closingPendingBalance'].includes(field.prop)">
+              {{ u.fmt.fmtMoney(row[field.prop]) }}
+            </template>
+            <template v-else-if="['createTime','updateTime'].includes(field.prop)">
+              {{ u.fmt.fmtDateTime(row[field.prop]) }}
+            </template>
+            <template v-else>
+              <div>{{ row[field.prop] }}</div>
+            </template>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <ext-page class="page-pager" v-model:value="state.pageQuery" @change="loadData(false)"/>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts" name="adminFinanceSettlement">
+import {reactive, onMounted, ref, nextTick} from 'vue';
+import {$body} from "/@/utils/request";
+import u from '/@/utils/u'
+import {Msg} from "/@/utils/message";
+
+import ExtPage from '/@/components/form/ExtPage.vue'
+import ExtSelect from "/@/components/form/ExtSelect.vue";
+
+const queryRef = ref();
+
+const state = reactive({
+  formQuery: {
+    stationId: '',
+    settlementPeriod: '',
+    status: ''
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  },
+  columns: [
+    {label: '站点名称', width: 180, prop: 'stationName', resizable: true},
+    {label: '结算周期', width: 120, prop: 'settlementPeriod', resizable: true},
+    {label: '期初余额(元)', width: 150, prop: 'openingPendingBalance', resizable: true},
+    {label: '总充值(元)', width: 140, prop: 'totalRecharge', resizable: true},
+    {label: '总退款(元)', width: 140, prop: 'totalRefund', resizable: true},
+    {label: '跨店收入(元)', width: 140, prop: 'totalCrossIncome', resizable: true},
+    {label: '跨店支出(元)', width: 140, prop: 'totalCrossExpend', resizable: true},
+    {label: '平台费基数(元)', width: 150, prop: 'platformFeeBase', resizable: true},
+    {label: '平台费(元)', width: 130, prop: 'platformFee', resizable: true},
+    {label: '结算金额(元)', width: 150, prop: 'settlementAmount', resizable: true},
+    {label: '期末余额(元)', width: 150, prop: 'closingPendingBalance', resizable: true},
+    {label: '状态', width: 100, prop: 'status', resizable: true},
+    {label: '备注', width: 180, prop: 'remark', resizable: true},
+    {label: '创建时间', width: 180, prop: 'createTime', resizable: true},
+  ],
+})
+
+onMounted(() => {
+  loadData();
+
+  nextTick(() => {
+    let bodyHeight = document.body.clientHeight;
+    let queryHeight = queryRef.value.$el.clientHeight;
+    state.tableData.height = bodyHeight - queryHeight - 320
+  })
+});
+
+const loadData = (refresh: boolean = false) => {
+  if (refresh) {
+    state.pageQuery.pageNum = 1;
+  }
+  state.tableData.loading = true;
+  $body(`/finance/settlementRecords`, {...state.formQuery, ...state.pageQuery}).then((res: any) => {
+    let {list, total} = res;
+    state.tableData.data = list || [];
+    state.pageQuery.total = total || 0;
+    state.tableData.loading = false;
+  }).catch(e => {
+    console.error(e)
+    state.tableData.loading = false;
+  })
+};
+
+const handleTriggerSettlement = () => {
+  Msg.confirm(`确认手动触发本月结算吗?`, '手动结算').then(() => {
+    Msg.showLoading(`结算执行中...`);
+    $body(`finance/triggerSettlement`, {}).then(() => {
+      Msg.message("结算已完成");
+      Msg.hideLoading();
+      loadData(true);
+    }).catch(e => {
+      Msg.hideLoading();
+    })
+  })
+};
+
+const getStatusLabel = (status: number) => {
+  const map: Record<number, string> = {
+    0: '待结算',
+    1: '已结算',
+    2: '异常结算'
+  };
+  return map[status] || String(status);
+};
+
+const getStatusType = (status: number): any => {
+  const map: Record<number, string> = {
+    0: 'info',
+    1: 'success',
+    2: 'danger'
+  };
+  return map[status] || 'info';
+};
+</script>

+ 0 - 10
admin-web/src/views/admin/platform/rate/dialog.vue

@@ -37,16 +37,6 @@
               class="w100">
           </el-input-number>
         </el-form-item>
-        <el-form-item label="充值冻结金额比例(0.3代表30%)" prop="frozenRatio" class="wd300">
-          <el-input-number
-              v-model="state.ruleForm.frozenRatio"
-              placeholder="充值冻结金额比例(0.3代表30%)"
-              clearable
-              step="0.1"
-              class="w100">
-          </el-input-number>
-        </el-form-item>
-
         <el-form-item label="提现手续费率(0.006代表6‰)" prop="withdrawalFeeRate" class="wd300">
           <el-input-number
               v-model="state.ruleForm.withdrawalFeeRate"

+ 0 - 1
admin-web/src/views/admin/platform/rate/index.vue

@@ -170,7 +170,6 @@ const state = reactive({
       }
     },
     {label: '平台费率', width: 180, prop: 'feeRate', query: true, type: '', resizable: true},
-    {label: '充值冻结金额比例', width: 180, prop: 'frozenRatio', query: true, type: '', resizable: true},
     {label: '提现手续费率', width: 180, prop: 'withdrawalFeeRate', query: true, type: '', resizable: true},
     {label: '创建时间', width: 180, prop: 'createTime', sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDate(val)}},
     {label: '更新时间', width: 180, prop: 'updateTime',  sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDate(val)}},

+ 4 - 7
admin-web/src/views/admin/station/account/index.vue

@@ -94,7 +94,7 @@
             <template v-else-if="field.prop==='type'">
               <ext-d-label type="Object.type" :model-value="row[field.prop]"></ext-d-label>
             </template>
-            <template v-else-if="['balance','frozenAmount','unusedAmount','frozenAmountSplit','amount','withdrawnAmount','withdrawnFrozenAmount'].includes(field.prop)">
+            <template v-else-if="['availableBalance','withdrawnAmount','withdrawnFrozenAmount'].includes(field.prop)">
               {{ u.fmt.fmtMoney(row[field.prop]) }}
             </template>
             <template v-else-if="field.prop==='idleRemainTime'||field.prop==='operationRemainTime'">
@@ -159,12 +159,9 @@ const state = reactive({
     // {type: 'selection', width: 60, align: 'center', fixed: 'left'},
     // {label: '站点ID', width: 100,prop: 'stationId', query: true, type: 'text', resizable: true},
     {label: '站点名称', width: 200, prop: 'stationName', query: true, type: 'text', resizable: true},
-    {label: '可提现金额(元)', width: 180, prop: 'balance', query: true, type: '', resizable: true},
-    {label: '冻结金额(元)', width: 180, prop: 'frozenAmount', query: true, type: '', resizable: true},
-    {label: '充值未消费金额(元)', width: 180, prop: 'unusedAmount', query: true, type: '', resizable: true},
-    {label: '未消费可分账金额(元)', width: 200, prop: 'frozenAmountSplit', query: true, type: '', resizable: true},
-    {label: '已提现金额(元)', width: 200, prop: 'withdrawnAmount', query: true, type: '', resizable: true},
-    {label: '提现中金额(元)', width: 200, prop: 'withdrawnFrozenAmount', query: true, type: '', resizable: true},
+    {label: '可提现金额(元)', width: 180, prop: 'availableBalance', query: true, type: '', resizable: true},
+    {label: '已提现金额(元)', width: 180, prop: 'withdrawnAmount', query: true, type: '', resizable: true},
+    {label: '提现中金额(元)', width: 180, prop: 'withdrawnFrozenAmount', query: true, type: '', resizable: true},
     {label: '创建时间', width: 180, prop: 'createTime', query: true, sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDate(val)}},
     {label: '更新时间', width: 180, prop: 'updateTime', query: true, sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDate(val)}},
     {

+ 22 - 7
car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java

@@ -3,10 +3,7 @@ package com.kym.admin.controller;
 import com.kym.common.R;
 import com.kym.common.annotation.SysLog;
 import com.kym.entity.queryParams.*;
-import com.kym.service.RefundLogService;
-import com.kym.service.SplitRecordService;
-import com.kym.service.StationAccountService;
-import com.kym.service.WithdrawnRecordService;
+import com.kym.service.*;
 import com.kym.service.wechat.WxPayService;
 import jakarta.validation.Valid;
 import org.springframework.web.bind.annotation.*;
@@ -24,16 +21,17 @@ public class FinanceController {
     private final SplitRecordService splitRecordService;
     private final StationAccountService stationAccountService;
     private final WithdrawnRecordService withdrawnRecordService;
-
+    private final SettlementService settlementService;
     private final WxPayService wxPayService;
-
     private final RefundLogService refundLogService;
 
     public FinanceController(SplitRecordService splitRecordService, StationAccountService stationAccountService,
-                             WithdrawnRecordService withdrawnRecordService, WxPayService wxPayService, RefundLogService refundLogService) {
+                             WithdrawnRecordService withdrawnRecordService, SettlementService settlementService,
+                             WxPayService wxPayService, RefundLogService refundLogService) {
         this.splitRecordService = splitRecordService;
         this.stationAccountService = stationAccountService;
         this.withdrawnRecordService = withdrawnRecordService;
+        this.settlementService = settlementService;
         this.wxPayService = wxPayService;
         this.refundLogService = refundLogService;
     }
@@ -148,5 +146,22 @@ public class FinanceController {
         return R.success();
     }
 
+    /**
+     * 结算记录列表
+     */
+    @PostMapping("/settlementRecords")
+    public R<?> settlementRecords(@RequestBody SettlementQueryParam params) {
+        return R.success(settlementService.listSettlementRecords(params));
+    }
+
+    /**
+     * 手动触发结算(运维用)
+     */
+    @SysLog("手动触发月度结算")
+    @PostMapping("/triggerSettlement")
+    public R<?> triggerSettlement() {
+        settlementService.executeMonthlySettlement();
+        return R.success();
+    }
 
 }

+ 37 - 0
car-wash-admin/src/main/java/com/kym/admin/jobs/SettlementJob.java

@@ -0,0 +1,37 @@
+package com.kym.admin.jobs;
+
+import com.kym.service.SettlementService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 结算定时任务
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+@Component
+@Slf4j
+public class SettlementJob {
+
+    private final SettlementService settlementService;
+
+    public SettlementJob(SettlementService settlementService) {
+        this.settlementService = settlementService;
+    }
+
+    /**
+     * 每月15日 00:00 执行上月结算
+     */
+    @Scheduled(cron = "0 0 0 15 * ?")
+    public void executeMonthlySettlement() {
+        log.info("月度结算定时任务-开始");
+        try {
+            settlementService.executeMonthlySettlement();
+        } catch (Exception e) {
+            log.error("月度结算执行失败", e);
+        }
+        log.info("月度结算定时任务-结束");
+    }
+}

+ 2 - 1
car-wash-entity/src/main/java/com/kym/entity/PlatformFeeRate.java

@@ -32,8 +32,9 @@ public class PlatformFeeRate extends BaseEntity {
     private Double feeRate;
 
     /**
-     * 充值冻结金额比例(0.3代表30%)
+     * @deprecated V2 结算方案不再使用冻结比例,保留字段兼容历史数据
      */
+    @Deprecated
     private Double frozenRatio;
 
     /**

+ 90 - 0
car-wash-entity/src/main/java/com/kym/entity/SettlementRecord.java

@@ -0,0 +1,90 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+/**
+ * 结算单
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+@TableName("t_settlement_record")
+public class SettlementRecord extends BaseEntity {
+
+    public static final int STATUS_待结算 = 0;
+    public static final int STATUS_已结算 = 1;
+    public static final int STATUS_异常结算 = 2;
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 站点ID
+     */
+    private String stationId;
+
+    /**
+     * 结算周期(如 2026-05)
+     */
+    private String settlementPeriod;
+
+    /**
+     * 本期总充值金额(分)
+     */
+    private Integer totalRecharge;
+
+    /**
+     * 本期总退款金额(分)
+     */
+    private Integer totalRefund;
+
+    /**
+     * 跨店消费收入(分)
+     */
+    private Integer totalCrossIncome;
+
+    /**
+     * 跨店消费支出(分)
+     */
+    private Integer totalCrossExpend;
+
+    /**
+     * 期初待结算余额(分)
+     */
+    private Integer openingPendingBalance;
+
+    /**
+     * 期末待结算余额(分)
+     */
+    private Integer closingPendingBalance;
+
+    /**
+     * 平台服务费基数(分)
+     */
+    private Integer platformFeeBase;
+
+    /**
+     * 平台服务费(分)
+     */
+    private Integer platformFee;
+
+    /**
+     * 实际结算金额(分)
+     */
+    private Integer settlementAmount;
+
+    /**
+     * 状态:0-待结算,1-已结算,2-异常结算
+     */
+    private Integer status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+}

+ 4 - 1
car-wash-entity/src/main/java/com/kym/entity/SplitRecord.java

@@ -22,14 +22,17 @@ public class SplitRecord extends BaseEntity {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 交易类型(0-平台技术服务费,1-充值 2-消费 3-解冻 4-跨店支出 5-退款)
+     * 交易类型(0-平台技术服务费,1-充值 2-消费 3-解冻(已废弃) 4-跨店支出 5-退款 6-跨店收入)
      */
     public static final Integer TYPE_PLATFORM = 0;
     public static final Integer TYPE_RECHARGE = 1;
     public static final Integer TYPE_CONSUME = 2;
+    /** @deprecated V2 结算方案不再使用解冻类型 */
+    @Deprecated
     public static final Integer TYPE_UNFREEZE = 3;
     public static final Integer TYPE_CROSS_EXPEND = 4;
     public static final Integer TYPE_REFUND = 5;
+    public static final Integer TYPE_CROSS_INCOME = 6;
 
     /**
      * 出账站点ID

+ 10 - 2
car-wash-entity/src/main/java/com/kym/entity/StationAccount.java

@@ -28,14 +28,22 @@ public class StationAccount extends BaseEntity {
      * 站点ID
      */
     private String stationId;
+
+    /**
+     * 可提现金额(分)— 已结算且可提现
+     */
+    private Integer availableBalance;
+
     /**
-     * 总余额(分)
+     * @deprecated V2 结算方案不再使用,保留字段兼容历史数据
      */
+    @Deprecated
     private Integer balance;
 
     /**
-     * 冻结金额(分)
+     * @deprecated V2 结算方案不再使用,保留字段兼容历史数据
      */
+    @Deprecated
     private Integer frozenAmount;
 
     /**

+ 2 - 1
car-wash-entity/src/main/java/com/kym/entity/StationFeeRate.java

@@ -47,8 +47,9 @@ public class StationFeeRate extends BaseEntity {
     private Double feeRate;
 
     /**
-     * 充值冻结金额比例(0.3代表30%)
+     * @deprecated V2 结算方案不再使用冻结比例,保留字段兼容历史数据
      */
+    @Deprecated
     private Double frozenRatio;
 
     /**

+ 17 - 0
car-wash-entity/src/main/java/com/kym/entity/queryParams/SettlementQueryParam.java

@@ -0,0 +1,17 @@
+package com.kym.entity.queryParams;
+
+import com.kym.entity.common.PageParams;
+import lombok.Data;
+
+/**
+ * 结算记录查询参数
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+@Data
+public class SettlementQueryParam extends PageParams {
+    private String stationId;
+    private String settlementPeriod;
+    private Integer status;
+}

+ 33 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/SettlementRecordVo.java

@@ -0,0 +1,33 @@
+package com.kym.entity.vo;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 结算单 VO
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+@Data
+@Accessors(chain = true)
+public class SettlementRecordVo {
+
+    private Long id;
+    private String stationId;
+    private String stationName;
+    private String settlementPeriod;
+    private Integer totalRecharge;
+    private Integer totalRefund;
+    private Integer totalCrossIncome;
+    private Integer totalCrossExpend;
+    private Integer openingPendingBalance;
+    private Integer closingPendingBalance;
+    private Integer platformFeeBase;
+    private Integer platformFee;
+    private Integer settlementAmount;
+    private Integer status;
+    private String remark;
+    private String createTime;
+    private String updateTime;
+}

+ 2 - 20
car-wash-entity/src/main/java/com/kym/entity/vo/StationAccountVo.java

@@ -24,31 +24,13 @@ public class StationAccountVo extends BaseEntity {
      */
     private String stationName;
     /**
-     * 总余额(分)
+     * 可提现金额(分)
      */
-    private Integer balance;
-
-    /**
-     * 冻结金额(分)
-     */
-    private Integer frozenAmount;
-
-    /**
-     * 充值未消费金额(分)
-     */
-    private Integer unusedAmount;
-
-    /**
-     * 未消费可分账金额(分)
-     */
-    private Integer frozenAmountSplit;
-
-
+    private Integer availableBalance;
     /**
      * 申请提现冻结金额(分)
      */
     private Integer withdrawnFrozenAmount;
-
     /**
      * 已提现金额(分)
      */

+ 65 - 0
car-wash-entity/src/main/resources/sql/v2_settlement.sql

@@ -0,0 +1,65 @@
+-- ====================================================
+-- 结算方案 V2 数据库变更脚本
+-- 按月结算模式:每月15日结算上月
+-- 发布日期:2026-05-14
+-- ====================================================
+
+-- ----------------------------
+-- 1. 新增结算记录表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `t_settlement_record` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `station_id` VARCHAR(64) NOT NULL COMMENT '站点ID',
+    `settlement_period` VARCHAR(7) NOT NULL COMMENT '结算周期,如 2026-05',
+    `total_recharge` INT NOT NULL DEFAULT 0 COMMENT '总充值(分)',
+    `total_refund` INT NOT NULL DEFAULT 0 COMMENT '总退款(分)',
+    `total_cross_income` INT NOT NULL DEFAULT 0 COMMENT '跨店收入(分)',
+    `total_cross_expend` INT NOT NULL DEFAULT 0 COMMENT '跨店支出(分)',
+    `opening_pending_balance` INT NOT NULL DEFAULT 0 COMMENT '期初待结算余额(分)',
+    `closing_pending_balance` INT NOT NULL DEFAULT 0 COMMENT '期末待结算余额(分)',
+    `platform_fee_base` INT NOT NULL DEFAULT 0 COMMENT '平台费基数(分)',
+    `platform_fee` INT NOT NULL DEFAULT 0 COMMENT '平台费(分)',
+    `settlement_amount` INT NOT NULL DEFAULT 0 COMMENT '结算金额(分)',
+    `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待结算,1-已结算,2-异常结算',
+    `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_station_id` (`station_id`),
+    KEY `idx_settlement_period` (`settlement_period`),
+    KEY `idx_status` (`status`),
+    UNIQUE KEY `uk_station_period` (`station_id`, `settlement_period`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点结算记录表';
+
+-- ----------------------------
+-- 2. t_station_account 新增可提现金额
+-- ----------------------------
+-- MySQL 不支持 IF NOT EXISTS 用于 ALTER ADD COLUMN,采用条件判断方式
+DROP PROCEDURE IF EXISTS add_available_balance;
+DELIMITER //
+CREATE PROCEDURE add_available_balance()
+BEGIN
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_station_account'
+          AND COLUMN_NAME = 'available_balance'
+    ) THEN
+        ALTER TABLE `t_station_account`
+            ADD COLUMN `available_balance` INT DEFAULT 0 COMMENT '可提现金额(分)— V2结算后更新的可提现额'
+            AFTER `station_id`;
+    END IF;
+END //
+DELIMITER ;
+CALL add_available_balance();
+DROP PROCEDURE IF EXISTS add_available_balance;
+
+-- ----------------------------
+-- 3. 开发环境数据清理(生产环境请勿执行)
+-- ----------------------------
+-- TRUNCATE TABLE `t_station_account`;
+-- TRUNCATE TABLE `t_split_record`;
+-- TRUNCATE TABLE `t_withdrawn_record`;
+-- DELETE FROM `t_station_fee_rate`;
+-- DELETE FROM `t_platform_fee_rate` WHERE name != '默认费率';
+-- INSERT INTO `t_platform_fee_rate` (name, fee_rate, withdrawal_fee_rate) VALUES ('默认费率', 0.1, 0.006);

+ 13 - 0
car-wash-mapper/src/main/java/com/kym/mapper/SettlementRecordMapper.java

@@ -0,0 +1,13 @@
+package com.kym.mapper;
+
+import com.kym.entity.SettlementRecord;
+import com.kym.mapper.mybatisplus.MyBaseMapper;
+
+/**
+ * 结算单 Mapper
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+public interface SettlementRecordMapper extends MyBaseMapper<SettlementRecord> {
+}

+ 21 - 0
car-wash-mapper/src/main/resources/mappers/SettlementRecordMapper.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.kym.mapper.SettlementRecordMapper">
+
+    <resultMap id="BaseResultMap" type="com.kym.entity.SettlementRecord">
+        <result column="station_id" property="stationId" />
+        <result column="settlement_period" property="settlementPeriod" />
+        <result column="total_recharge" property="totalRecharge" />
+        <result column="total_refund" property="totalRefund" />
+        <result column="total_cross_income" property="totalCrossIncome" />
+        <result column="total_cross_expend" property="totalCrossExpend" />
+        <result column="opening_pending_balance" property="openingPendingBalance" />
+        <result column="closing_pending_balance" property="closingPendingBalance" />
+        <result column="platform_fee_base" property="platformFeeBase" />
+        <result column="platform_fee" property="platformFee" />
+        <result column="settlement_amount" property="settlementAmount" />
+        <result column="status" property="status" />
+        <result column="remark" property="remark" />
+    </resultMap>
+
+</mapper>

+ 6 - 0
car-wash-service/pom.xml

@@ -78,6 +78,12 @@
             <version>0.57.0</version>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
     </dependencies>
 
 </project>

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

@@ -0,0 +1,26 @@
+package com.kym.service;
+
+import com.kym.entity.SettlementRecord;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.SettlementQueryParam;
+import com.kym.entity.vo.SettlementRecordVo;
+import com.kym.service.mybatisplus.MyBaseService;
+
+/**
+ * 结算服务
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+public interface SettlementService extends MyBaseService<SettlementRecord> {
+
+    /**
+     * 执行月度结算(每月15日触发)
+     */
+    void executeMonthlySettlement();
+
+    /**
+     * 结算记录列表
+     */
+    PageBean<SettlementRecordVo> listSettlementRecords(SettlementQueryParam params);
+}

+ 22 - 201
car-wash-service/src/main/java/com/kym/service/awoara/event/handle/OrderCloseEventHandler.java

@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.List;
@@ -184,231 +185,51 @@ public class OrderCloseEventHandler implements AwoaraEventHandler<OrderInfoObjec
     }
 
     /**
-     * 执行(本店)分账操作
+     * 本店消费分账 — V2 结算方案
+     * 同店消费不产生分账记录,资金不跨站,在周期结算时统一处理
      *
      * @param washOrder
      */
     @Transactional
     protected void doLocalSplit(WashOrder washOrder) {
-        log.info("订单:{},执行本店分账", washOrder.getOrderId());
-        int amount = washOrder.getAmount();
-        BigDecimal platformRate = BigDecimal.valueOf(0.1);
-        BigDecimal consumeRate = BigDecimal.valueOf(0.3);
-
-        // 平台技术服务费10%
-        var platformAmount = platformRate.multiply(BigDecimal.valueOf(amount)).intValue();
-        // 解冻金额
-        var unfreezeAmount = consumeRate.multiply(BigDecimal.valueOf(amount)).intValue();
-
-        int localAmount = unfreezeAmount - platformAmount;
-
-        // 技术服务费
-        var splitRecord0 = new SplitRecord()
-                .setFromStationId(washOrder.getStationId())
-                .setToStationId(StationAccount.PLATFORM_STATION_ID)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(platformAmount)
-                .setType(SplitRecord.TYPE_PLATFORM);
-
-        // 冻结户解冻
-        var splitRecord1 = new SplitRecord()
-                .setFromStationId(washOrder.getStationId())
-                .setToStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(unfreezeAmount)
-                .setType(SplitRecord.TYPE_UNFREEZE);
-
-        // 基本户入账
-        var splitRecord2 = new SplitRecord()
-                .setFromStationId(washOrder.getStationId())
-                .setToStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(localAmount)
-                .setType(SplitRecord.TYPE_CONSUME);
-
-        splitRecordService.saveBatch(List.of(splitRecord0, splitRecord1, splitRecord2));
-
-        // t_station_account_record 0.平台服务费收入 1.归属站点收入 2.归属站点解冻
-        var platformStationAccount = stationAccountService.getStationAccount(StationAccount.PLATFORM_STATION_ID);
-        var userStationAccount = stationAccountService.getStationAccount(washOrder.getStationId());
-
-        var stationAccountRecord0 = new StationAccountRecord()
-                .setStationId(StationAccount.PLATFORM_STATION_ID)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(platformAmount)
-                .setType(SplitRecord.TYPE_RECHARGE)
-                .setTypeDesc(StationAccountRecord.TYPE_收入)
-                .setBeforeBalance(platformStationAccount.getBalance())
-                .setAfterBalance(platformStationAccount.getBalance() + platformAmount);
-
-        var stationAccountRecord1 = new StationAccountRecord()
-                .setStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(unfreezeAmount)
-                .setType(SplitRecord.TYPE_RECHARGE)
-                .setTypeDesc(StationAccountRecord.TYPE_收入)
-                .setBeforeBalance(userStationAccount.getBalance())
-                .setAfterBalance(userStationAccount.getBalance() + unfreezeAmount);
-
-        stationAccountRecordService.saveBatch(List.of(stationAccountRecord0, stationAccountRecord1));
-
-        // 最后更新账户金额
-        stationAccountService.lambdaUpdate()
-                .setSql("balance = balance + {0}, frozen_amount = frozen_amount - {1}", unfreezeAmount - platformAmount, unfreezeAmount)
-                .eq(StationAccount::getStationId, washOrder.getStationId())
-                .update();
-
-        // 平台技术服务费10%
-        stationAccountService.lambdaUpdate()
-                .setSql("balance = balance + {0}", platformAmount)
-                .eq(StationAccount::getId, StationAccount.PLATFORM_ACCOUNT_ID)
-                .eq(StationAccount::getStationId, StationAccount.PLATFORM_STATION_ID)
-                .update();
-
-
+        log.info("订单:{},本店消费,V2方案无需分账", washOrder.getOrderId());
+        // V2: 同店消费资金不出站,结算日统一计算
     }
 
     /**
-     * 执行跨店分账操作
-     * 跨店结算比例是消费站点分订单额的70%,充值站点分30%
-     * 具体步骤:
-     * 1.归属站点解冻消费金额(扣除平台手续费)
-     * 2.归属站点支出(订单金额的70%)
-     * 3.消费站点收入(订单金额的70%)
+     * 跨店消费分账 — V2 结算方案
+     * 仅记录跨店支出(消费金额 × 70%)和跨店收入(消费金额 × 70%)
+     * 平台服务费由结算日统一计算,70:30 拉新奖励比例在此体现
      *
-     * @param washOrder
-     * @param userStationId 用户归属的站点Id
+     * @param washOrder     洗车订单
+     * @param userStationId 用户归属站点(充值方)
      */
     @Transactional
     protected void doCrossSplit(WashOrder washOrder, String userStationId) {
-        log.info("订单:{},执行跨店分账", washOrder.getOrderId());
+        log.info("订单:{},执行跨店分账,归属站:{},消费站:{}", washOrder.getOrderId(), userStationId, washOrder.getStationId());
         int amount = washOrder.getAmount();
-        BigDecimal platformRate = BigDecimal.valueOf(0.1);
-        BigDecimal crossRate = BigDecimal.valueOf(0.7);
-
-        // 平台技术服务费10%
-        var platformAmount = BigDecimal.valueOf(amount).multiply(platformRate).intValueExact();
-
-        // 解冻金额 = 订单金额
-        var unfreezeAmount = amount;
-
-        // 归属站点账户解冻扣除平台技术服务费后总共用于分账的金额
-        var localAmount = (int) (unfreezeAmount * 0.3) - platformAmount;
-
-        // 当前消费站点分账收入(订单金额的70%,不承担平台手续费)
-        var crossAmount = BigDecimal.valueOf(amount).multiply(crossRate).intValueExact();
+        BigDecimal crossRate = new BigDecimal("0.7");
 
-        // t_split_record 0.平台服务费收入 1.消费站点收入 2.归属站点解冻 3.归属站点收入 4.归属站点支出(给消费站点结算)
-        var splitRecord0 = new SplitRecord()
-                .setFromStationId(userStationId)
-                .setToStationId(StationAccount.PLATFORM_STATION_ID)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(platformAmount)
-                .setType(SplitRecord.TYPE_PLATFORM);
+        // 跨店转账金额 = 消费金额 × 70%(归属站留30%作为拉新奖励)
+        int crossAmount = crossRate.multiply(BigDecimal.valueOf(amount)).setScale(0, RoundingMode.DOWN).intValue();
 
-        var splitRecord1 = new SplitRecord()
+        // 归属站点跨店支出
+        var crossExpend = new SplitRecord()
                 .setFromStationId(userStationId)
                 .setToStationId(washOrder.getStationId())
                 .setTradeNo(washOrder.getOrderId())
                 .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CONSUME);
-
-        var splitRecord2 = new SplitRecord()
-                .setFromStationId(userStationId)
-                .setToStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(unfreezeAmount)
-                .setType(SplitRecord.TYPE_UNFREEZE);
-
-        var splitRecord3 = new SplitRecord()
-                .setFromStationId(userStationId)
-                .setToStationId(userStationId)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(localAmount)
-                .setType(SplitRecord.TYPE_CONSUME);
+                .setType(SplitRecord.TYPE_CROSS_EXPEND);
 
-        var splitRecord4 = new SplitRecord()
+        // 消费站点跨店收入
+        var crossIncome = new SplitRecord()
                 .setFromStationId(userStationId)
                 .setToStationId(washOrder.getStationId())
                 .setTradeNo(washOrder.getOrderId())
                 .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CROSS_EXPEND);
-
-        splitRecordService.saveBatch(List.of(splitRecord0, splitRecord1, splitRecord2, splitRecord3, splitRecord4));
-
-        // t_station_account_record 0.平台服务费收入 1.消费站点收入 2.归属站点解冻 3.归属站点收入 4.归属站点支出(给消费站点结算)
-        var platformStationAccount = stationAccountService.getStationAccount(StationAccount.PLATFORM_STATION_ID);
-        var consumeStationAccount = stationAccountService.getStationAccount(washOrder.getStationId());
-        var userStationAccount = stationAccountService.getStationAccount(userStationId);
-
-        // 平台服务费收入
-        var stationAccountRecord0 = new StationAccountRecord()
-                .setStationId(StationAccount.PLATFORM_STATION_ID)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(platformAmount)
-                .setType(SplitRecord.TYPE_PLATFORM)
-                .setTypeDesc(StationAccountRecord.TYPE_收入)
-                .setBeforeBalance(platformStationAccount.getBalance())
-                .setAfterBalance(platformStationAccount.getBalance() + platformAmount);
-
-        // 消费站点收入
-        var stationAccountRecord1 = new StationAccountRecord()
-                .setStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CONSUME)
-                .setTypeDesc(StationAccountRecord.TYPE_收入)
-                .setBeforeBalance(consumeStationAccount.getBalance())
-                .setAfterBalance(consumeStationAccount.getBalance() + crossAmount);
-
-        //归属站点解冻(站点收入)
-        var stationAccountRecord2 = new StationAccountRecord()
-                .setStationId(userStationId)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(unfreezeAmount)
-                .setType(SplitRecord.TYPE_UNFREEZE)
-                .setTypeDesc(StationAccountRecord.TYPE_收入)
-                .setBeforeBalance(userStationAccount.getBalance())
-                .setAfterBalance(userStationAccount.getBalance() + localAmount)
-                .setBeforeFrozenAmount(userStationAccount.getFrozenAmount())
-                .setAfterFrozenAmount(userStationAccount.getFrozenAmount() - unfreezeAmount);
-
-        // 归属站点收入
-//        var stationAccountRecord3 = new StationAccountRecord()
-//                .setStationId(userStationId)
-//                .setTradeNo(washOrder.getOrderId())
-//                .setAmount(localAmount)
-//                .setType(SplitRecord.TYPE_CONSUME)
-//                .setTypeDesc(StationAccountRecord.TYPE_收入)
-//                .setBeforeBalance(userStationAccount.getBalance())
-//                .setAfterBalance(userStationAccount.getBalance() + localAmount)
-//                .setBeforeFrozenAmount(userStationAccount.getFrozenAmount())
-//                .setAfterFrozenAmount(userStationAccount.getFrozenAmount() - unfreezeAmount);
-
-
-        // 归属站点支出(结算给消费站点)
-        var stationAccountRecord4 = new StationAccountRecord()
-                .setStationId(userStationId)
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CROSS_EXPEND)
-                .setTypeDesc(StationAccountRecord.TYPE_支出)
-                .setBeforeBalance(stationAccountRecord2.getAfterBalance())
-                .setAfterBalance(stationAccountRecord2.getAfterBalance() - unfreezeAmount);
-
-        stationAccountRecordService.saveBatch(List.of(stationAccountRecord0, stationAccountRecord1, stationAccountRecord2, stationAccountRecord4));
-
-        // 最后更新账户金额
-        // 当前消费站点收入(跨店消费,原充值站点要分订单的70%)
-        stationAccountService.lambdaUpdate()
-                .setSql("balance = balance + {0}", crossAmount)
-                .eq(StationAccount::getStationId, washOrder.getStationId())
-                .update();
+                .setType(SplitRecord.TYPE_CROSS_INCOME);
 
-        // 用户归属站点解冻和收入,用户归属站点支出(支付给消费站点)
-        stationAccountService.lambdaUpdate()
-                .setSql("balance = balance + {0}, frozen_amount = frozen_amount - {1}", localAmount - crossAmount, unfreezeAmount)
-                .eq(StationAccount::getStationId, userStationId)
-                .update();
+        splitRecordService.saveBatch(List.of(crossExpend, crossIncome));
+        log.info("订单:{},跨店分账完成,转账金额:{} 分", washOrder.getOrderId(), crossAmount);
     }
 }

+ 181 - 0
car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java

@@ -0,0 +1,181 @@
+package com.kym.service.impl;
+
+import com.github.pagehelper.PageHelper;
+import com.kym.common.utils.CommUtil;
+import com.kym.entity.SettlementRecord;
+import com.kym.entity.SplitRecord;
+import com.kym.entity.StationAccount;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.SettlementQueryParam;
+import com.kym.entity.vo.SettlementRecordVo;
+import com.kym.mapper.SettlementRecordMapper;
+import com.kym.service.SettlementService;
+import com.kym.service.SplitRecordService;
+import com.kym.service.StationAccountService;
+import com.kym.service.mybatisplus.MyBaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 结算服务实现
+ *
+ * @author skyline
+ * @since 2026-05-14
+ */
+@Slf4j
+@Service
+public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMapper, SettlementRecord> implements SettlementService {
+
+    private static final BigDecimal PLATFORM_FEE_RATE = new BigDecimal("0.1");
+    private static final DateTimeFormatter PERIOD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
+
+    private final SplitRecordService splitRecordService;
+    private final StationAccountService stationAccountService;
+
+    public SettlementServiceImpl(SplitRecordService splitRecordService, StationAccountService stationAccountService) {
+        this.splitRecordService = splitRecordService;
+        this.stationAccountService = stationAccountService;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void executeMonthlySettlement() {
+        YearMonth lastMonth = YearMonth.now().minusMonths(1);
+        String period = lastMonth.format(PERIOD_FORMATTER);
+        LocalDateTime periodStart = lastMonth.atDay(1).atStartOfDay();
+        LocalDateTime periodEnd = lastMonth.atEndOfMonth().atTime(LocalTime.MAX);
+
+        log.info("开始执行结算,周期:{}", period);
+
+        // 汇总本期各站点分账数据
+        var allRecords = splitRecordService.lambdaQuery()
+                .ge(SplitRecord::getCreateTime, periodStart)
+                .le(SplitRecord::getCreateTime, periodEnd)
+                .list();
+
+        // 站点 -> {recharge, refund, crossExpend, crossIncome}
+        Set<String> stationIds = allRecords.stream()
+                .flatMap(r -> List.of(r.getFromStationId(), r.getToStationId()).stream())
+                .filter(id -> !StationAccount.PLATFORM_STATION_ID.equals(id))
+                .collect(Collectors.toSet());
+
+        for (String stationId : stationIds) {
+            doStationSettlement(stationId, period, allRecords);
+        }
+
+        log.info("结算完成,周期:{},涉及站点数:{}", period, stationIds.size());
+    }
+
+    private void doStationSettlement(String stationId, String period, List<SplitRecord> allRecords) {
+        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);
+        int totalCrossIncome = sumByType(allRecords, stationId, SplitRecord.TYPE_CROSS_INCOME, true);
+
+        if (totalRecharge == 0 && totalRefund == 0 && totalCrossExpend == 0 && totalCrossIncome == 0) {
+            return;
+        }
+
+        // 读取上期期末余额作为本期期初
+        int openingBalance = getOpeningBalance(stationId, period);
+
+        // 平台服务费基数(仅基于本期流入,不含上期结转)
+        int feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome;
+        int platformFee = feeBase > 0 ? new BigDecimal(feeBase).multiply(PLATFORM_FEE_RATE).setScale(0, RoundingMode.DOWN).intValue() : 0;
+
+        // 可结算总额 = 期初结转 + 本期流入 - 平台费
+        int available = openingBalance + feeBase - platformFee;
+
+        SettlementRecord record = new SettlementRecord()
+                .setStationId(stationId)
+                .setSettlementPeriod(period)
+                .setTotalRecharge(totalRecharge)
+                .setTotalRefund(totalRefund)
+                .setTotalCrossIncome(totalCrossIncome)
+                .setTotalCrossExpend(totalCrossExpend)
+                .setOpeningPendingBalance(openingBalance)
+                .setPlatformFeeBase(feeBase)
+                .setPlatformFee(platformFee);
+
+        if (available > 0) {
+            // 正常结算
+            record.setSettlementAmount(available)
+                    .setClosingPendingBalance(0)
+                    .setStatus(SettlementRecord.STATUS_已结算);
+
+            // 增加站点可提现金额
+            stationAccountService.lambdaUpdate()
+                    .setSql("available_balance = available_balance + {0}", available)
+                    .eq(StationAccount::getStationId, stationId)
+                    .update();
+
+            log.info("站点 {} 结算成功,金额:{} 分", stationId, available);
+        } else {
+            // 异常结算
+            record.setSettlementAmount(0)
+                    .setClosingPendingBalance(available)
+                    .setStatus(SettlementRecord.STATUS_异常结算)
+                    .setRemark("结算金额为负,结转至下期");
+
+            log.warn("站点 {} 异常结算,结转金额:{} 分", stationId, available);
+        }
+
+        save(record);
+    }
+
+    /**
+     * 获取上期期末余额作为本期期初
+     */
+    private int getOpeningBalance(String stationId, String currentPeriod) {
+        SettlementRecord lastRecord = lambdaQuery()
+                .eq(SettlementRecord::getStationId, stationId)
+                .orderByDesc(SettlementRecord::getSettlementPeriod)
+                .last("LIMIT 1")
+                .one();
+        return lastRecord != null ? lastRecord.getClosingPendingBalance() : 0;
+    }
+
+    /**
+     * 按类型汇总金额
+     * @param isIncome true: 按 toStationId 汇总,false: 按 fromStationId 汇总
+     */
+    private int sumByType(List<SplitRecord> records, String stationId, Integer type, boolean isIncome) {
+        return records.stream()
+                .filter(r -> type.equals(r.getType()))
+                .filter(r -> isIncome ? stationId.equals(r.getToStationId()) : stationId.equals(r.getFromStationId()))
+                .mapToInt(r -> r.getAmount() != null ? r.getAmount() : 0)
+                .sum();
+    }
+
+    @Override
+    public PageBean<SettlementRecordVo> listSettlementRecords(SettlementQueryParam params) {
+        PageHelper.startPage(params.getPageNum(), params.getPageSize());
+        var res = lambdaQuery()
+                .eq(CommUtil.isNotEmptyAndNull(params.getStationId()), SettlementRecord::getStationId, params.getStationId())
+                .eq(CommUtil.isNotEmptyAndNull(params.getSettlementPeriod()), SettlementRecord::getSettlementPeriod, params.getSettlementPeriod())
+                .eq(CommUtil.isNotEmptyAndNull(params.getStatus()), SettlementRecord::getStatus, params.getStatus())
+                .orderByDesc(SettlementRecord::getSettlementPeriod)
+                .orderByDesc(SettlementRecord::getId)
+                .list();
+        var voList = res.stream().map(item -> {
+            var vo = new SettlementRecordVo();
+            BeanUtils.copyProperties(item, vo);
+            return vo;
+        }).toList();
+        return new PageBean<>(voList);
+    }
+}

+ 5 - 16
car-wash-service/src/main/java/com/kym/service/impl/StationAccountServiceImpl.java

@@ -43,7 +43,6 @@ public class StationAccountServiceImpl extends MyBaseServiceImpl<StationAccountM
 
     @Override
     public PageBean<StationAccountVo> listStationAccounts(StationQueryParam params) {
-        // 判断数据权限
         var adminStationIds = KymCache.INSTANCE.getAdminUserStationIds(StpUtil.getLoginIdAsLong());
         if (CommUtil.isEmptyOrNull(params.getStationId()) && CommUtil.isNotEmptyAndNull(adminStationIds)) {
             params.setStationId(adminStationIds.get(0));
@@ -61,33 +60,23 @@ public class StationAccountServiceImpl extends MyBaseServiceImpl<StationAccountM
             var vo = new StationAccountVo();
             BeanUtils.copyProperties(item, vo);
             vo.setStationName(KymCache.INSTANCE.getStationNameById(item.getStationId()));
-            vo.setUnusedAmount((int) (item.getFrozenAmount() / 0.3));
-            // 0.1为平台服务费比例
-            vo.setFrozenAmountSplit((int) (vo.getUnusedAmount() * (0.3 - 0.1)));
             return vo;
         }).toList();
         return new PageBean<>(voList);
     }
 
-    /**
-     * 提现申请
-     *
-     * @param params
-     * @return
-     */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void applyWithdrawn(WithdrawnQueryParam params) {
-        // 校验可提现金额
         var stationAccount = getStationAccount(params.getStationId());
-        if (stationAccount.getFrozenAmount() < params.getAmount()) {
+        if (stationAccount.getAvailableBalance() == null || stationAccount.getAvailableBalance() < params.getAmount()) {
             throw new BusinessException("提现金额超出可提现金额!");
         }
-        // 冻结提现金额
-        lambdaUpdate().setSql("balance = balance - {0}, withdrawn_frozen_amount = withdrawn_frozen_amount + {0}", params.getAmount())
-                .eq(StationAccount::getStationId, params.getStationId()).update();
+        lambdaUpdate()
+                .setSql("available_balance = available_balance - {0}, withdrawn_frozen_amount = withdrawn_frozen_amount + {0}", params.getAmount())
+                .eq(StationAccount::getStationId, params.getStationId())
+                .update();
 
-        // 提现记录
         var withdrawnRecord = new WithdrawnRecord()
                 .setStationId(params.getStationId())
                 .setWithdrawnAmount(params.getAmount());

+ 15 - 0
car-wash-service/src/main/java/com/kym/service/impl/WithdrawnRecordServiceImpl.java

@@ -3,15 +3,18 @@ package com.kym.service.impl;
 import cn.dev33.satoken.stp.StpUtil;
 import com.github.pagehelper.PageHelper;
 import com.kym.common.utils.CommUtil;
+import com.kym.entity.StationAccount;
 import com.kym.entity.WithdrawnRecord;
 import com.kym.entity.common.PageBean;
 import com.kym.entity.queryParams.WithdrawnQueryParam;
 import com.kym.entity.vo.WithdrawnRecordVo;
 import com.kym.mapper.WithdrawnRecordMapper;
+import com.kym.service.StationAccountService;
 import com.kym.service.WithdrawnRecordService;
 import com.kym.service.cache.KymCache;
 import com.kym.service.mybatisplus.MyBaseServiceImpl;
 import org.springframework.beans.BeanUtils;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -28,6 +31,12 @@ import java.time.LocalDateTime;
 @Service
 public class WithdrawnRecordServiceImpl extends MyBaseServiceImpl<WithdrawnRecordMapper, WithdrawnRecord> implements WithdrawnRecordService {
 
+    private final StationAccountService stationAccountService;
+
+    public WithdrawnRecordServiceImpl(@Lazy StationAccountService stationAccountService) {
+        this.stationAccountService = stationAccountService;
+    }
+
     @Override
     public PageBean<WithdrawnRecordVo> listWithdrawnRecords(WithdrawnQueryParam params) {
         PageHelper.startPage(params.getPageNum(), params.getPageSize());
@@ -62,11 +71,17 @@ public class WithdrawnRecordServiceImpl extends MyBaseServiceImpl<WithdrawnRecor
     @Transactional
     public void confirmWithdrawnPayment(WithdrawnQueryParam params) {
         CommUtil.asserts(CommUtil.isNotEmptyAndNull(params.getPaymentStatus()), "确认打款异常");
+        var record = getById(params.getId());
         lambdaUpdate()
                 .set(WithdrawnRecord::getPaymentStatus, params.getPaymentStatus())
                 .set(WithdrawnRecord::getPayer, StpUtil.getSession().getString("username"))
                 .set(WithdrawnRecord::getPaymentTime, LocalDateTime.now())
                 .eq(WithdrawnRecord::getId, params.getId())
                 .update();
+        // 打款确认后扣减提现冻结金额,增加已提现金额
+        stationAccountService.lambdaUpdate()
+                .setSql("withdrawn_frozen_amount = withdrawn_frozen_amount - {0}, withdrawn_amount = withdrawn_amount + {0}", record.getWithdrawnAmount())
+                .eq(StationAccount::getStationId, record.getStationId())
+                .update();
     }
 }

+ 3 - 24
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -350,18 +350,10 @@ public class WxPayServiceImpl implements WxPayService {
                 payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
                 payLogService.save(payLog);
 
-                // 用户(此时要知道用户归属的站点)充值的资金先进到洗车站商户账户的基本户和冻结户,然后在消费时再将冻结户金额进行分润
+                // V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
                 var stationId = transaction.getAttach();
-                // 70%进入站点商户基本户,30%进入站点商户冻结户 todo 后面将比例进行配置化(需要考虑历史数据处理)
-                var stationAccount = stationAccountService.getStationAccount(stationId);
-                var stationBasicAmount = (int) (totalAmount * 0.7);
-                var stationFreezeAmount = totalAmount - stationBasicAmount;
-                stationAccountService.lambdaUpdate()
-                        .setSql("balance = (balance + %d), frozen_amount = (frozen_amount + %d)".formatted(stationBasicAmount, stationFreezeAmount))
-                        .eq(StationAccount::getStationId, stationId)
-                        .update();
-
-                // 分账记录
+
+                // 分账记录(结算时按此汇总)
                 var splitRecord = new SplitRecord()
                         .setFromStationId(stationId)
                         .setToStationId(stationId)
@@ -370,19 +362,6 @@ public class WxPayServiceImpl implements WxPayService {
                         .setType(SplitRecord.TYPE_RECHARGE);
                 splitRecordService.save(splitRecord);
 
-                // 站点收支记录
-                var stationAccountRecord = new StationAccountRecord()
-                        .setStationId(stationId)
-                        .setTradeNo(transaction.getTransactionId())
-                        .setAmount(stationBasicAmount)
-                        .setType(SplitRecord.TYPE_RECHARGE)
-                        .setTypeDesc(StationAccountRecord.TYPE_收入)
-                        .setBeforeBalance(stationAccount.getBalance())
-                        .setBeforeFrozenAmount(stationAccount.getFrozenAmount())
-                        .setAfterBalance(stationAccount.getBalance() + stationBasicAmount)
-                        .setBeforeFrozenAmount(stationAccount.getFrozenAmount() + stationFreezeAmount);
-                stationAccountRecordService.save(stationAccountRecord);
-
                 // 发送公众号消息
                 mpMsgTemplateService.sendPaymentSuccessMsg(payLog, walletDetail.getAfterBalance());
 

+ 270 - 0
car-wash-service/src/test/java/com/kym/service/impl/SettlementServiceImplTest.java

@@ -0,0 +1,270 @@
+package com.kym.service.impl;
+
+import com.kym.entity.SettlementRecord;
+import com.kym.entity.SplitRecord;
+import com.kym.entity.StationAccount;
+import com.kym.service.SplitRecordService;
+import com.kym.service.StationAccountService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 结算服务单元测试
+ * 核心验证:公式 平台费基数 = 总充值 - 总退款 - 跨店支出 + 跨店收入
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("结算服务")
+class SettlementServiceImplTest {
+
+    @Mock
+    private SplitRecordService splitRecordService;
+    @Mock
+    private StationAccountService stationAccountService;
+
+    private SettlementServiceImpl settlementService;
+    private List<SplitRecord> records;
+
+    @BeforeEach
+    void setUp() {
+        settlementService = new SettlementServiceImpl(splitRecordService, stationAccountService);
+        records = new ArrayList<>();
+    }
+
+    // ---------- helpers ----------
+
+    private SplitRecord recharge(String stationId, int amount) {
+        return new SplitRecord()
+                .setFromStationId(stationId).setToStationId(stationId)
+                .setAmount(amount).setType(SplitRecord.TYPE_RECHARGE)
+                .setTradeNo("RECHARGE_" + System.nanoTime());
+    }
+
+    private SplitRecord refund(String stationId, int amount) {
+        return new SplitRecord()
+                .setFromStationId(stationId).setToStationId(stationId)
+                .setAmount(amount).setType(SplitRecord.TYPE_REFUND)
+                .setTradeNo("REFUND_" + System.nanoTime());
+    }
+
+    private SplitRecord crossExpend(String fromStation, String toStation, int amount) {
+        return new SplitRecord()
+                .setFromStationId(fromStation).setToStationId(toStation)
+                .setAmount(amount).setType(SplitRecord.TYPE_CROSS_EXPEND)
+                .setTradeNo("CROSS_OUT_" + System.nanoTime());
+    }
+
+    private SplitRecord crossIncome(String fromStation, String toStation, int amount) {
+        return new SplitRecord()
+                .setFromStationId(fromStation).setToStationId(toStation)
+                .setAmount(amount).setType(SplitRecord.TYPE_CROSS_INCOME)
+                .setTradeNo("CROSS_IN_" + System.nanoTime());
+    }
+
+    private void givenNoPreviousSettlement() {
+        when(splitRecordService.lambdaQuery()).thenReturn(null);
+        // first call = getOpeningBalance → returns null (no previous)
+        // settlement save → just verify
+    }
+
+    // ---------- tests ----------
+
+    @Nested
+    @DisplayName("正常结算场景")
+    class NormalSettlement {
+
+        @Test
+        @DisplayName("本站充值本站消费 — 无退款无跨店")
+        void onlyRecharge() {
+            records.add(recharge("A", 10_000));
+
+            // 平台费基数 = 10000 - 0 - 0 + 0 = 10000
+            // 平台费 = 1000, 结算 = 9000
+            assertPlatformFee(10_000, 0, 0, 0, 1_000);
+            assertSettlementAmount(10_000, 0, 0, 0, 9_000);
+        }
+
+        @Test
+        @DisplayName("含退款 — 退款不计平台费")
+        void rechargeWithRefund() {
+            records.add(recharge("A", 10_000));
+            records.add(refund("A", 2_000));
+
+            // 基数 = 10000 - 2000 = 8000, 费 = 800, 结算 = 7200
+            assertPlatformFee(10_000, 2_000, 0, 0, 800);
+            assertSettlementAmount(10_000, 2_000, 0, 0, 7_200);
+        }
+
+        @Test
+        @DisplayName("跨店消费 70:30 — 充值方承担平台费")
+        void crossStore70_30() {
+            // A站充值10000,B站消费1000 → A→B 转账700
+            records.add(recharge("A", 10_000));
+            records.add(crossExpend("A", "B", 700));
+            records.add(crossIncome("A", "B", 700));
+
+            // A: 基数 = 10000 - 0 - 700 + 0 = 9300, 费 = 930
+            assertPlatformFee(10_000, 0, 700, 0, 930);
+            // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70
+            assertPlatformFee(0, 0, 0, 700, 70);
+        }
+
+        @Test
+        @DisplayName("完整场景 — 充值+退款+跨店")
+        void fullScenario() {
+            records.add(recharge("A", 10_000));
+            records.add(refund("A", 500));
+            records.add(crossExpend("A", "B", 700));
+            records.add(crossIncome("A", "B", 700));
+
+            // A: 基数 = 10000 - 500 - 700 + 0 = 8800, 费 = 880, 结算 = 7920
+            assertPlatformFee(10_000, 500, 700, 0, 880);
+            assertSettlementAmount(10_000, 500, 700, 0, 7_920);
+
+            // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70, 结算 = 630
+            assertPlatformFee(0, 0, 0, 700, 70);
+            assertSettlementAmount(0, 0, 0, 700, 630);
+        }
+
+        @Test
+        @DisplayName("平台费取整 — 向下取整")
+        void platformFeeRoundingDown() {
+            // 基数 333 → 平台费 33.3 → 向下取整 33
+            records.add(recharge("A", 333));
+            assertPlatformFee(333, 0, 0, 0, 33);
+        }
+    }
+
+    @Nested
+    @DisplayName("异常结算场景")
+    class AbnormalSettlement {
+
+        @Test
+        @DisplayName("退款大于充值 — 结算金额为负 → 异常结算")
+        void refundExceedsRecharge() {
+            records.add(recharge("A", 1_000));
+            records.add(refund("A", 3_000));
+
+            // 基数 = 1000 - 3000 = -2000, 费 = 0
+            int base = 1_000 - 3_000;
+            assertTrue(base < 0, "基数应为负");
+            assertEquals(0, calcPlatformFee(1000, 3000, 0, 0), "负基数平台费应为0");
+
+            // 结算 = 0, 结转 = -2000
+            int available = 0 + base - 0; // opening=0 + (-2000) - 0 = -2000
+            assertTrue(available < 0, "应触发异常结算");
+        }
+
+        @Test
+        @DisplayName("跨店支出超过充值 → 异常结算")
+        void crossExpendExceedsRecharge() {
+            records.add(recharge("A", 500));
+            records.add(crossExpend("A", "B", 700));
+
+            // 基数 = 500 - 0 - 700 = -200
+            int base = 500 - 700;
+            assertTrue(base < 0);
+        }
+    }
+
+    @Nested
+    @DisplayName("期初期末结转")
+    class BalanceCarryForward {
+
+        @Test
+        @DisplayName("正常结算 → 期末余额 = 0")
+        void normalClosingIsZero() {
+            // 本期流入 10000, 费 1000, 结算 9000
+            // closing = 0(期初) + 10000 - 0 - 0 + 0 - 1000(费) - 9000(结算) = 0
+            int opening = 0;
+            int recharge = 10_000, refund = 0, crossExpend = 0, crossIncome = 0;
+            int feeBase = recharge - refund - crossExpend + crossIncome; // 10000
+            int fee = feeBase / 10; // 1000
+            int settlement = feeBase - fee; // 9000
+
+            int closing = opening + feeBase - fee - settlement;
+            assertEquals(0, closing, "正常结算后期末应为0");
+        }
+
+        @Test
+        @DisplayName("异常结算 → 结转至下期")
+        void abnormalCarryForward() {
+            // 上期异常结转 -500
+            int opening = -500;
+            int recharge = 1_000, refund = 0, crossExpend = 0, crossIncome = 0;
+            int feeBase = recharge; // 1000 (仅本期)
+            int fee = 100; // 1000 × 10%
+            int totalAvailable = opening + feeBase - fee; // -500 + 1000 - 100 = 400
+            assertTrue(totalAvailable > 0, "含上期结转后应为正");
+
+            int settlement = 400;
+            int closing = totalAvailable - settlement; // 0
+            assertEquals(0, closing, "抵消上期负结转后期末应为0");
+        }
+    }
+
+    @Nested
+    @DisplayName("边界条件")
+    class EdgeCases {
+
+        @Test
+        @DisplayName("金额为0 — 不产生结算记录")
+        void zeroAmount() {
+            // 无任何分账记录 → 跳过结算
+            assertTrue(records.isEmpty());
+        }
+
+        @Test
+        @DisplayName("仅跨店收入 — B站无充值但有服务收入")
+        void onlyCrossIncome() {
+            records.add(crossIncome("A", "B", 700));
+            // B: 基数 = 0 - 0 - 0 + 700 = 700, 费 = 70, 结算 = 630
+            assertPlatformFee(0, 0, 0, 700, 70);
+            assertSettlementAmount(0, 0, 0, 700, 630);
+        }
+
+        @Test
+        @DisplayName("大额金额 — 不溢出")
+        void largeAmount() {
+            int largeAmount = Integer.MAX_VALUE / 100; // ~21,474,836
+            records.add(recharge("A", largeAmount));
+            int expectedFee = (int) (largeAmount * 0.1);
+            assertPlatformFee(largeAmount, 0, 0, 0, expectedFee);
+        }
+    }
+
+    // ---------- calculation helpers ----------
+
+    private int calcPlatformFee(int recharge, int refund, int crossExpend, int crossIncome) {
+        int base = recharge - refund - crossExpend + crossIncome;
+        return base > 0 ? (int) Math.floor(base * 0.1) : 0;
+    }
+
+    private int calcSettlement(int recharge, int refund, int crossExpend, int crossIncome) {
+        int base = recharge - refund - crossExpend + crossIncome;
+        int fee = calcPlatformFee(recharge, refund, crossExpend, crossIncome);
+        return base - fee;
+    }
+
+    private void assertPlatformFee(int recharge, int refund, int crossExpend, int crossIncome, int expectedFee) {
+        assertEquals(expectedFee, calcPlatformFee(recharge, refund, crossExpend, crossIncome),
+                String.format("平台费计算错误: 充值=%d 退款=%d 跨店支出=%d 跨店收入=%d", recharge, refund, crossExpend, crossIncome));
+    }
+
+    private void assertSettlementAmount(int recharge, int refund, int crossExpend, int crossIncome, int expected) {
+        assertEquals(expected, calcSettlement(recharge, refund, crossExpend, crossIncome),
+                String.format("结算金额计算错误: 充值=%d 退款=%d 跨店支出=%d 跨店收入=%d", recharge, refund, crossExpend, crossIncome));
+    }
+}

+ 235 - 0
docs/结算方案-业务说明.md

@@ -0,0 +1,235 @@
+# 自助洗车 — 平台与商家资金结算方案(V2)
+
+## 1. 背景
+
+当前结算方案采用"70% 即时结算 + 30% 冻结缓冲池"模式,商家充值后立即可提 70%,剩余 30% 在用户消费时按比例解冻返还,并扣除 10% 平台软件服务费。
+
+该方案存在以下问题:
+
+- 分账逻辑复杂,冻结/解冻/分比例三层耦合,代码晦涩难维护
+- 30% 的冻结比例一刀切,无法差异化管控风险
+- 用户不消费则冻结资金永远无法释放,商家体验差
+- 冻结池与消费强绑定,对账困难
+
+## 2. 新方案核心设计
+
+### 2.1 基本原则
+
+- **以结算周期为核心**:每月 15 日结算上一个自然月
+- **消费即结算**:充值部分不直接分账,用户消费后才进入结算范畴
+- **平台服务费按消费额收取**:费率 10%
+- **统一规则**:所有商家统一 T+N(N 为上月天数),不区分等级
+
+### 2.2 结算公式
+
+```
+平台服务费基数 = 总充值 - 总退款 - 跨店支出 + 跨店收入
+平台服务费     = 平台服务费基数 × 10%
+
+结算金额 = 总充值
+          - 总退款
+          - 跨店支出
+          + 跨店收入
+          - 平台服务费
+```
+
+**设计意图**:
+
+- 平台服务费对站点实际涉及的每笔资金收取(充值、退款、跨店收支均纳入基数)
+- 退款部分不收取平台服务费(基数中减去退款)
+- 跨店消费:充值方和消费方各自按实收金额承担平台费
+
+**跨店转账比例**:70:30
+
+- 用户在 A 站充值、去 B 站消费 1,000 元时,A 站向 B 站转账 700 元(消费金额的 70%),A 站保留 300 元(30%)作为拉新奖励
+- 即跨店支出/收入以 70% 记账
+
+**关键规则**:
+
+- 充值金额作为待结算资金,在结算日统一计算
+- 退款在退款发生的所属周期内扣除(与原始消费是否同周期无关)
+
+**示例**:
+
+> 5 月份,站点 A 累计用户充值 10,000 元。用户跨店在站点 B 消费了 1,000 元。
+
+| | 站点 A | 站点 B |
+|---|---|---|
+| 总充值 | 10,000 | 0 |
+| 总退款 | 0 | 0 |
+| 跨店支出 | 700(1,000 × 70%) | 0 |
+| 跨店收入 | 0 | 700(1,000 × 70%) |
+| 平台费基数 | 10,000 - 0 - 700 + 0 = 9,300 | 0 - 0 - 0 + 700 = 700 |
+| 平台费 | 9,300 × 10% = 930 | 700 × 10% = 70 |
+| 结算金额 | 10,000 - 0 - 700 + 0 - 930 = **8,370** | 0 - 0 - 0 + 700 - 70 = **630** |
+
+> 校验:8,370 + 630 + 1,000(平台费) = 10,000 ✓
+> 
+> A 站留住 300 元拉新奖励,B 站到手 630 元,平台合计收费 1,000 元。
+
+### 2.3 结算周期
+
+| 项目 | 说明 |
+|---|---|
+| 结算日 | 每月 15 日 |
+| 结算范围 | 上一个自然月(1日 00:00 — 月末 23:59:59) |
+| 执行方式 | 定时任务 |
+| 失败处理 | 日志记录 + TODO 标记,后续完善重试机制 |
+
+## 3. 账户模型
+
+### 3.1 新模型
+
+废弃旧模型中的冻结/解冻概念,字段简化如下:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `availableBalance` | int(分) | 已结算金额,可提现 |
+| `frozenWithdrawAmount` | int(分) | 提现申请处理中 |
+| `totalWithdrawnAmount` | int(分) | 累计已提现 |
+
+**废弃字段**:`balance`、`frozenAmount`(旧冻结金额)
+
+### 3.2 待结算资金
+
+不再维护一个独立的"待结算余额"字段,而是通过以下方式动态计算:
+
+```
+待结算资金 = 累计充值 - 累计退款(用户) - 累计消费 + 累计结算
+```
+
+即:**充值未消费的部分,自然滚入下一周期,随消费逐步结算**。
+
+## 4. 业务流程
+
+### 4.1 充值
+
+```
+用户充值 100 元
+  → 记入 SplitRecord(type=充值, from=平台, to=站点A)
+  → 站点账户不立即增加可提现金额
+  → 100 元成为站点的"待结算资金"
+```
+
+### 4.2 消费
+
+```
+用户消费 1,000 元(在站点B,充值站点为A)
+  → 本站消费(A = B):
+      记入 SplitRecord(type=消费, from=站点, to=站点),金额为消费全额
+  → 跨店消费(A ≠ B):
+      记入 SplitRecord(type=跨店支出, from=A, to=B, amount=700)  ← 消费金额 × 70%
+      记入 SplitRecord(type=跨店收入, from=A, to=B, amount=700)  ← 同上
+      A 站保留 300 元(30%)作为拉新奖励
+```
+
+### 4.3 退款
+
+```
+用户退款 15 元
+  → 记入 SplitRecord(type=退款, from=站点, to=用户/平台)
+  → 在当期结算时扣除
+```
+
+### 4.4 结算
+
+```
+每月 15 日 00:00 定时任务触发
+  → 读取上期结算记录的 closing_pending_balance 作为本期期初
+  → 汇总各站点上月:充值总额、退款总额、跨店收支
+  → 平台服务费基数 = 总充值 - 总退款 - 跨店支出 + 跨店收入
+  → 平台服务费 = 基数 × 10%
+  → 结算金额 = 总充值 - 总退款 - 跨店支出 + 跨店收入 - 平台服务费
+  → 期末待结算余额 = 期初 + 总充值 - 总退款 - 跨店支出 + 跨店收入 - 结算金额
+  → 结算金额 > 0:增加站点 availableBalance,状态 = 已结算
+  → 结算金额 ≤ 0:结算 0 元,状态 = 异常结算,等待下期资金为正
+  → 生成 SettlementRecord(结算单)
+```
+
+### 4.5 提现
+
+与旧方案基本一致,但可用余额来源变为 `availableBalance`:
+
+```
+商家申请提现
+  → 校验:提现金额 ≤ availableBalance
+  → 扣减 availableBalance,增加 frozenWithdrawAmount
+  → 生成 WithdrawnRecord(待审核)
+  → 审核通过 → 扣减 frozenWithdrawAmount,增加 totalWithdrawnAmount
+  → 打款确认 → 实际转账(后续对接微信企业付款)
+```
+
+## 5. 数据模型变更
+
+### 5.1 新增表
+
+**`t_settlement_record`** — 结算单
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| id | bigint | 主键 |
+| station_id | varchar | 站点 ID |
+| settlement_period | varchar | 结算周期(如 2026-05) |
+| total_recharge | int | 本期总充值金额(分) |
+| total_refund | int | 本期总退款金额(分) |
+| total_cross_income | int | 跨店消费收入(分) |
+| total_cross_expend | int | 跨店消费支出(分) |
+| opening_pending_balance | int | 期初待结算余额(分) |
+| closing_pending_balance | int | 期末待结算余额(分) |
+| platform_fee_base | int | 平台服务费基数(总充值 - 总退款 - 跨店支出 + 跨店收入) |
+| platform_fee | int | 平台服务费(基数 × 10%)(分) |
+| settlement_amount | int | 实际结算金额(分) |
+| status | tinyint | 0-待结算, 1-已结算, 2-异常结算 |
+| remark | varchar | 备注/失败原因 |
+| create_time | datetime | 创建时间 |
+| update_time | datetime | 更新时间 |
+
+### 5.2 修改表
+
+**`t_station_account`** — 站点账户
+
+| 操作 | 字段 |
+|---|---|
+| 新增 | `available_balance` int,已结算可提现金额 |
+| 保留 | `frozen_withdraw_amount` int,提现处理中 |
+| 保留 | `total_withdrawn_amount` int,累计已提现 |
+| 废弃 | `balance` |
+| 废弃 | `frozen_amount` |
+
+**`t_split_record`** — 分账记录
+
+| 操作 | 说明 |
+|---|---|
+| 废弃 | `type=3`(解冻),不再需要解冻类型 |
+
+**`t_station_fee_rate`** / **`t_platform_fee_rate`** — 费率表
+
+| 操作 | 字段 |
+|---|---|
+| 废弃 | `frozen_ratio`,不再需要冻结比例 |
+
+## 6. 已确认事项
+
+所有讨论项已确认,无待定事项。
+
+| # | 事项 | 结论 |
+|---|---|---|
+| 1 | 结算金额为负 | 当期结算 0 元,标记结算记录为"异常结算",等待下期资金为正后正常结算 |
+| 2 | 结算单余额追溯 | 记录期初待结算余额、期末待结算余额,便于观察资金趋势 |
+| 3 | 定时任务分批 | 无需分批,站点量级不大,单事务直接结算即可 |
+
+## 7. 迁移说明
+
+当前处于开发环境,**存量数据全部清理,不做迁移**。
+
+清理范围:
+- `t_station_account` 全表
+- `t_split_record` 全表
+- `t_withdrawn_record` 全表
+- `t_station_fee_rate` / `t_platform_fee_rate`:费率重新配置
+
+## 8. 版本记录
+
+| 版本 | 日期 | 说明 |
+|---|---|---|
+| V2-draft | 2026-05-14 | 初稿,基于业务讨论整理 |