ソースを参照

feat: 新增数据统计模块 — 日/月统计表与手动同步兜底

扩展 t_daily_stat 和 t_month_stat 字段(退款/充值/活跃用户/平台费),
新增 t_stat_sync_log 同步日志表。StatJob 重构为委托 service 层统一
同步逻辑,定时任务与手动同步共享同一代码路径。前端新增「数据统计」
菜单组,含日统计/月统计页面及「重新同步」按钮,支持按日期/月份全局
重新计算统计数据。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 日 前
コミット
01114afd1d
36 ファイル変更1897 行追加133 行削除
  1. 18 0
      admin-h5/src/api/finance.js
  2. 8 10
      admin-h5/src/components/NavBar.vue
  3. 275 0
      admin-h5/src/pages/finance/refund-detail.vue
  4. 204 14
      admin-h5/src/pages/finance/refund.vue
  5. 7 8
      admin-h5/src/pages/index/index.vue
  6. 51 1
      admin-web/src/router/route.ts
  7. 193 0
      admin-web/src/views/admin/statistics/daily-stat.vue
  8. 182 0
      admin-web/src/views/admin/statistics/month-stat.vue
  9. 29 5
      car-wash-admin/src/main/java/com/kym/admin/controller/DailyStatController.java
  10. 10 0
      car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java
  11. 29 5
      car-wash-admin/src/main/java/com/kym/admin/controller/MonthStatController.java
  12. 11 76
      car-wash-admin/src/main/java/com/kym/admin/jobs/StatJob.java
  13. 21 0
      car-wash-entity/src/main/java/com/kym/entity/DailyStat.java
  14. 25 0
      car-wash-entity/src/main/java/com/kym/entity/MonthStat.java
  15. 40 0
      car-wash-entity/src/main/java/com/kym/entity/StatSyncLog.java
  16. 22 0
      car-wash-entity/src/main/java/com/kym/entity/queryParams/DailyStatQueryParam.java
  17. 19 0
      car-wash-entity/src/main/java/com/kym/entity/queryParams/MonthStatQueryParam.java
  18. 22 0
      car-wash-entity/src/main/java/com/kym/entity/vo/DailyStatVo.java
  19. 22 0
      car-wash-entity/src/main/java/com/kym/entity/vo/MonthStatVo.java
  20. 189 0
      car-wash-entity/src/main/resources/sql/v10_daily_monthly_stat.sql
  21. 13 0
      car-wash-mapper/src/main/java/com/kym/mapper/StatSyncLogMapper.java
  22. 10 1
      car-wash-mapper/src/main/resources/mappers/DailyStatMapper.xml
  23. 7 1
      car-wash-mapper/src/main/resources/mappers/MonthStatMapper.xml
  24. 17 0
      car-wash-mapper/src/main/resources/mappers/StatSyncLogMapper.xml
  25. 6 2
      car-wash-service/src/main/java/com/kym/service/DailyStatService.java
  26. 7 3
      car-wash-service/src/main/java/com/kym/service/MonthStatService.java
  27. 12 0
      car-wash-service/src/main/java/com/kym/service/SplitRecordService.java
  28. 13 0
      car-wash-service/src/main/java/com/kym/service/StatSyncLogService.java
  29. 4 0
      car-wash-service/src/main/java/com/kym/service/WashOrderService.java
  30. 148 3
      car-wash-service/src/main/java/com/kym/service/impl/DailyStatServiceImpl.java
  31. 161 4
      car-wash-service/src/main/java/com/kym/service/impl/MonthStatServiceImpl.java
  32. 54 0
      car-wash-service/src/main/java/com/kym/service/impl/SplitRecordServiceImpl.java
  33. 17 0
      car-wash-service/src/main/java/com/kym/service/impl/StatSyncLogServiceImpl.java
  34. 24 0
      car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java
  35. 2 0
      car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java
  36. 25 0
      car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

+ 18 - 0
admin-h5/src/api/finance.js

@@ -72,6 +72,24 @@ export const processRefund = (id) => {
   return get(`/finance/customWxRefund/${id}`)
 }
 
+/**
+ * 批量处理退款
+ * @param {Array<number>} ids - 退款记录ID列表
+ * @returns {Promise} 批量退款处理结果
+ */
+export const batchProcessRefund = (ids) => {
+  return post('/finance/batchWxRefund', ids)
+}
+
+/**
+ * 获取退款详情
+ * @param {string} outRefundNo - 商户退款单号
+ * @returns {Promise} 退款详情
+ */
+export const getRefundDetail = (outRefundNo) => {
+  return get(`/finance/refundLog/detail/${outRefundNo}`)
+}
+
 /**
  * 获取结算记录列表
  * @param {Object} params - 查询参数

+ 8 - 10
admin-h5/src/components/NavBar.vue

@@ -3,7 +3,7 @@
     <view class="nav-bar__body">
       <view class="nav-bar__left">
         <view v-if="showBack" class="nav-back-btn" hover-class="nav-back-btn--hover" @click="handleBack">
-          <AppIcon name="chevron-left" :size="22" color="#FFFFFF" />
+          <AppIcon name="chevron-left" :size="22" color="#333333" />
         </view>
       </view>
       <text class="nav-bar__title">{{ title }}</text>
@@ -39,23 +39,21 @@ const handleBack = () => {
   position: sticky;
   top: 0;
   z-index: 100;
-  background: linear-gradient(135deg, #C6171E 0%, #D32F2F 100%);
-  box-shadow: 0 4px 16px rgba(198, 23, 30, 0.2);
+  background: #FFFFFF;
+  border-bottom: 1rpx solid #F0F0F0;
 }
 
 .nav-bar--transparent {
   background: transparent;
-  box-shadow: none;
+  border-bottom: none;
 }
 
 .nav-bar__body {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  height: 88rpx;
-  padding: 0 24rpx;
-  padding-top: var(--status-bar-height, 0px);
-  box-sizing: content-box;
+  padding: 24rpx 24rpx;
+  padding-top: calc(24rpx + var(--status-bar-height, 0px));
 }
 
 .nav-bar__left,
@@ -86,7 +84,7 @@ const handleBack = () => {
 }
 
 .nav-back-btn--hover {
-  background: rgba(255, 255, 255, 0.18);
+  background: rgba(0, 0, 0, 0.06);
 }
 
 .nav-bar__title {
@@ -94,7 +92,7 @@ const handleBack = () => {
   text-align: center;
   font-size: 34rpx;
   font-weight: 600;
-  color: #FFFFFF;
+  color: #1A1A1A;
   letter-spacing: 1rpx;
   white-space: nowrap;
   overflow: hidden;

+ 275 - 0
admin-h5/src/pages/finance/refund-detail.vue

@@ -0,0 +1,275 @@
+<template>
+  <view class="detail-container">
+    <!-- 状态卡片 -->
+    <view class="status-card">
+      <text class="status-text" :style="{ color: statusColor }">{{ fmtDictName('RefundLog.status', detail.status) }}</text>
+      <text class="status-amount">¥{{ formatAmount(detail.refund) }}</text>
+      <text class="status-label">退款金额</text>
+    </view>
+
+    <!-- 退款信息 -->
+    <view class="section">
+      <view class="section-title">退款信息</view>
+      <view class="info-card">
+        <view class="info-row" @click="showFullText('商户退款单号', detail.outRefundNo)">
+          <text class="info-label">商户退款单号</text>
+          <text class="info-value">{{ detail.outRefundNo || '-' }}</text>
+        </view>
+        <view class="info-row" @click="showFullText('商户订单号', detail.outTradeNo)">
+          <text class="info-label">商户订单号</text>
+          <text class="info-value">{{ detail.outTradeNo || '-' }}</text>
+        </view>
+        <view class="info-row" @click="showFullText('微信支付订单号', detail.transactionId)">
+          <text class="info-label">微信支付订单号</text>
+          <text class="info-value">{{ detail.transactionId || '-' }}</text>
+        </view>
+        <view class="info-row" @click="showFullText('微信退款单号', detail.refundId)">
+          <text class="info-label">微信退款单号</text>
+          <text class="info-value">{{ detail.refundId || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款原因</text>
+          <text class="info-value info-value-wrap">{{ detail.reason || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款渠道</text>
+          <text class="info-value">{{ detail.channel || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款入账账户</text>
+          <text class="info-value">{{ detail.userReceivedAccount || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">资金账户</text>
+          <text class="info-value">{{ detail.fundsAccount || '-' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 金额信息 -->
+    <view class="section">
+      <view class="section-title">金额明细</view>
+      <view class="info-card">
+        <view class="info-row">
+          <text class="info-label">原充值金额</text>
+          <text class="info-value amount">¥{{ formatAmount(detail.total) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款金额</text>
+          <text class="info-value amount-refund">¥{{ formatAmount(detail.refund) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">不可退优惠金额</text>
+          <text class="info-value">{{ formatAmount(detail.discountAmount) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">币种</text>
+          <text class="info-value">{{ detail.currency || 'CNY' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 操作信息 -->
+    <view class="section">
+      <view class="section-title">操作信息</view>
+      <view class="info-card">
+        <view class="info-row">
+          <text class="info-label">退款人</text>
+          <text class="info-value">{{ detail.adminUsername || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">申请时间</text>
+          <text class="info-value">{{ formatTime(detail.createTime) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款成功时间</text>
+          <text class="info-value">{{ detail.successTime ? formatTime(detail.successTime) : '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">更新时间</text>
+          <text class="info-value">{{ detail.updateTime ? formatTime(detail.updateTime) : '-' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { getRefundDetail } from '../../api/finance.js'
+import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
+import { loadDicts } from '../../utils/dict.js'
+
+const detail = ref({})
+const loading = ref(true)
+
+const statusColor = computed(() => {
+  return getDictColor('RefundLog.status', detail.value.status) || '#666666'
+})
+
+const showFullText = (label, value) => {
+  if (!value) return
+  uni.showModal({
+    title: label,
+    content: value,
+    showCancel: false,
+    confirmText: '关闭'
+  })
+}
+
+const loadDetail = async (outRefundNo) => {
+  loading.value = true
+  try {
+    const res = await getRefundDetail(outRefundNo)
+    if (res && res.code === 200) {
+      detail.value = res.data || {}
+    } else {
+      showToast(res?.msg || '加载退款详情失败')
+    }
+  } catch (error) {
+    showToast('加载退款详情失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+onLoad((options) => {
+  if (options && options.outRefundNo) {
+    loadDetail(options.outRefundNo)
+  }
+})
+
+onMounted(async () => {
+  await loadDicts()
+})
+</script>
+
+<style scoped>
+.detail-container {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  padding-bottom: 40rpx;
+}
+
+.status-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 48rpx 30rpx;
+  background-color: #FFFFFF;
+  margin-bottom: 20rpx;
+}
+
+.status-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  margin-bottom: 16rpx;
+}
+
+.status-amount {
+  font-size: 56rpx;
+  font-weight: 700;
+  color: #1A1A1A;
+  margin-bottom: 8rpx;
+}
+
+.status-label {
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.section {
+  padding: 0 20rpx;
+  margin-bottom: 20rpx;
+}
+
+.section-title {
+  font-size: 26rpx;
+  color: #999999;
+  padding: 16rpx 10rpx 12rpx;
+}
+
+.info-card {
+  background-color: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 8rpx 24rpx;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.06);
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #F5F5F5;
+}
+
+.info-row:last-child {
+  border-bottom: none;
+}
+
+.info-label {
+  font-size: 26rpx;
+  color: #999999;
+  flex-shrink: 0;
+}
+
+.info-value {
+  font-size: 26rpx;
+  color: #1A1A1A;
+  max-width: 420rpx;
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.info-value-wrap {
+  white-space: normal;
+  word-break: break-all;
+  text-overflow: clip;
+}
+
+.info-value.amount {
+  color: #C6171E;
+  font-weight: 500;
+}
+
+.info-value.amount-refund {
+  color: #52C41A;
+  font-weight: 500;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid rgba(0, 0, 0, 0.1);
+  border-top-color: #C6171E;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999999;
+  margin-top: 16rpx;
+}
+</style>

+ 204 - 14
admin-h5/src/pages/finance/refund.vue

@@ -11,15 +11,23 @@
       <button class="search-btn" @click="handleSearch">搜索</button>
     </view>
 
-    <!-- 状态筛选 -->
+    <!-- 状态筛选 + 全选 -->
     <view class="filter-bar">
-      <view
-        v-for="(option, index) in statusOptions"
-        :key="index"
-        class="filter-item"
-        :class="{ active: activeStatus === option.value }"
-        @click="handleStatusChange(option.value)">
-        <text>{{ option.label }}</text>
+      <scroll-view class="filter-scroll" scroll-x="true" :show-scrollbar="false">
+        <view class="filter-scroll-inner">
+          <view
+            v-for="(option, index) in statusOptions"
+            :key="index"
+            class="filter-item"
+            :class="{ active: activeStatus === option.value }"
+            @click="handleStatusChange(option.value)">
+            <text>{{ option.label }}</text>
+          </view>
+        </view>
+      </scroll-view>
+      <view class="filter-divider"></view>
+      <view class="filter-item select-all-item" :class="{ active: isSelectAll }" @click="toggleSelectAll">
+        <text>{{ isSelectAll ? '取消全选' : '全选待退款' }}</text>
       </view>
     </view>
 
@@ -28,9 +36,16 @@
       <view
         class="refund-item"
         v-for="(item, index) in list"
-        :key="index">
+        :key="index"
+        :class="{ selected: selectedIds.has(item.refundLogId) }"
+        @click="handleItemClick(item)">
         <view class="item-header">
           <view class="item-left">
+            <view class="checkbox-wrapper" v-if="isRefundableStatus(item.status)" @click.stop="toggleSelect(item)">
+              <view class="checkbox" :class="{ checked: selectedIds.has(item.refundLogId) }">
+                <AppIcon v-if="selectedIds.has(item.refundLogId)" name="check" :size="20" color="#FFFFFF" />
+              </view>
+            </view>
             <text class="item-phone">{{ item.mobilePhone || '-' }}</text>
           </view>
           <text class="status-tag" :style="getStatusStyle(item.status)">{{ fmtDictName('RefundLog.status', item.status) }}</text>
@@ -70,7 +85,7 @@
           </view>
         </view>
         <view class="item-footer" v-if="isRefundableStatus(item.status)">
-          <button class="refund-btn" @click="handleProcessRefund(item)">退款处理</button>
+          <button class="refund-btn" @click.stop="handleProcessRefund(item)">退款处理</button>
         </view>
       </view>
     </view>
@@ -95,13 +110,21 @@
       <view class="loading-spinner"></view>
       <text class="loading-text">加载中...</text>
     </view>
+
+    <!-- 批量操作栏 -->
+    <view class="batch-bar" v-if="selectedIds.size > 0">
+      <view class="batch-info">
+        <text class="batch-count">已选 {{ selectedIds.size }} 项</text>
+      </view>
+      <button class="batch-btn" @click="handleBatchRefund">批量退款处理</button>
+    </view>
   </view>
 </template>
 
 <script setup>
 import { ref, onMounted, computed } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
-import { getRefundLogs, processRefund } from '../../api/finance.js'
+import { getRefundLogs, processRefund, batchProcessRefund } from '../../api/finance.js'
 import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
@@ -113,6 +136,7 @@ const hasMore = ref(true)
 const loadMoreStatus = ref('more')
 const searchKeyword = ref('')
 const activeStatus = ref('')
+const selectedIds = ref(new Set())
 
 const statusOptions = computed(() => dictUtil.getDictFilterOptions('RefundLog.status'))
 
@@ -126,12 +150,52 @@ const isRefundableStatus = (status) => {
   return status == dictUtil.getDictValue('RefundLog.status', '待退款')
 }
 
+// 当前页所有可退款的ID列表
+const refundableIds = computed(() => {
+  return list.value
+    .filter(item => isRefundableStatus(item.status))
+    .map(item => item.refundLogId)
+})
+
+const isSelectAll = computed(() => {
+  const ids = refundableIds.value
+  return ids.length > 0 && ids.every(id => selectedIds.value.has(id))
+})
+
+const toggleSelect = (item) => {
+  const id = item.refundLogId
+  const next = new Set(selectedIds.value)
+  if (next.has(id)) {
+    next.delete(id)
+  } else {
+    next.add(id)
+  }
+  selectedIds.value = next
+}
+
+const toggleSelectAll = () => {
+  if (isSelectAll.value) {
+    selectedIds.value = new Set()
+  } else {
+    selectedIds.value = new Set(refundableIds.value)
+  }
+}
+
+const handleItemClick = (item) => {
+  if (item.outRefundNo) {
+    uni.navigateTo({
+      url: `/pages/finance/refund-detail?outRefundNo=${item.outRefundNo}`
+    })
+  }
+}
+
 const loadData = async (isLoadMore = false) => {
   if (!isLoadMore) {
     page.value = 1
     list.value = []
     hasMore.value = true
     loadMoreStatus.value = 'more'
+    selectedIds.value = new Set()
   } else {
     loadMoreStatus.value = 'loading'
   }
@@ -211,6 +275,37 @@ const handleProcessRefund = (item) => {
   })
 }
 
+const handleBatchRefund = () => {
+  const ids = [...selectedIds.value]
+  if (ids.length === 0) {
+    showToast('请选择退款记录')
+    return
+  }
+  uni.showModal({
+    title: '批量退款确认',
+    content: `确定对已选的 ${ids.length} 笔退款进行批量处理吗?`,
+    success: async (res) => {
+      if (res.confirm) {
+        uni.showLoading({ title: '批量退款处理中...', mask: true })
+        try {
+          const result = await batchProcessRefund(ids)
+          uni.hideLoading()
+          if (result && result.code === 200) {
+            showToast(`批量退款已提交,共${ids.length}笔`, 'success')
+            selectedIds.value = new Set()
+            loadData()
+          } else {
+            showToast(result?.msg || '批量退款处理失败')
+          }
+        } catch (error) {
+          uni.hideLoading()
+          showToast('批量退款处理失败')
+        }
+      }
+    }
+  })
+}
+
 onMounted(async () => {
   await loadDicts()
   loadData()
@@ -221,7 +316,7 @@ onMounted(async () => {
 .refund-container {
   min-height: 100vh;
   background-color: #F5F7FA;
-  padding-bottom: 60rpx;
+  padding-bottom: 140rpx;
 }
 
 .search-bar {
@@ -263,23 +358,51 @@ onMounted(async () => {
 
 .filter-bar {
   display: flex;
-  padding: 16rpx 20rpx;
+  align-items: center;
+  padding: 12rpx 20rpx;
   background-color: #FFFFFF;
+}
+
+.filter-scroll {
+  flex: 1;
+  min-width: 0;
+}
+
+.filter-scroll-inner {
+  display: flex;
   gap: 16rpx;
-  flex-wrap: wrap;
+  white-space: nowrap;
+}
+
+.filter-divider {
+  width: 1rpx;
+  height: 32rpx;
+  background-color: #E5E5E5;
+  margin: 0 16rpx;
+  flex-shrink: 0;
 }
+
 .filter-item {
   padding: 8rpx 24rpx;
   background-color: #F5F5F5;
   border-radius: 100rpx;
   font-size: 24rpx;
   color: #666666;
+  flex-shrink: 0;
+  white-space: nowrap;
 }
+
 .filter-item.active {
   background: #C6171E;
   color: #FFFFFF;
 }
 
+.select-all-item {
+  flex-shrink: 0;
+  color: #C6171E;
+  background-color: #FFF0F0;
+}
+
 .refund-list {
   padding: 20rpx;
 }
@@ -290,6 +413,10 @@ onMounted(async () => {
   margin-bottom: 16rpx;
   box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
 }
+.refund-item.selected {
+  background-color: #FFF5F5;
+  border: 2rpx solid #C6171E;
+}
 .item-header {
   display: flex;
   justify-content: space-between;
@@ -298,6 +425,31 @@ onMounted(async () => {
   border-bottom: 1rpx solid #F0F0F0;
   margin-bottom: 16rpx;
 }
+.item-left {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.checkbox-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 44rpx;
+  height: 44rpx;
+}
+.checkbox {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 8rpx;
+  border: 2rpx solid #D0D0D0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.checkbox.checked {
+  background-color: #C6171E;
+  border-color: #C6171E;
+}
 .item-phone {
   font-size: 30rpx;
   font-weight: 600;
@@ -385,4 +537,42 @@ onMounted(async () => {
   color: #999999;
   margin-top: 16rpx;
 }
+
+/* 批量操作栏 */
+.batch-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background-color: #FFFFFF;
+  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.08);
+  z-index: 100;
+}
+.batch-info {
+  display: flex;
+  align-items: center;
+}
+.batch-count {
+  font-size: 28rpx;
+  color: #1A1A1A;
+  font-weight: 500;
+}
+.batch-btn {
+  padding: 16rpx 40rpx;
+  background: #C6171E;
+  color: #FFFFFF;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  border: none;
+  font-weight: 500;
+}
+.batch-btn:active {
+  background: #A81212;
+  transform: scale(0.97);
+}
 </style>

+ 7 - 8
admin-h5/src/pages/index/index.vue

@@ -3,7 +3,7 @@
     <NavBar title="首页" :showBack="false">
       <template #right>
         <view class="nav-btn" @click="handleLogout">
-          <AppIcon name="log-out" :size="28" color="#FFFFFF" />
+          <AppIcon name="log-out" :size="28" color="#666666" />
         </view>
       </template>
     </NavBar>
@@ -106,31 +106,31 @@
       <view class="actions-row">
         <view class="action-item" @click="navigateTo('/pages/order/list')">
           <view class="action-icon-wrap">
-            <AppIcon name="clipboard" :size="26" color="#FFFFFF" />
+            <AppIcon name="clipboard" :size="26" color="#C6171E" />
           </view>
           <text class="action-label">订单管理</text>
         </view>
         <view class="action-item" @click="navigateTo('/pages/device/list')">
           <view class="action-icon-wrap">
-            <AppIcon name="monitor" :size="26" color="#FFFFFF" />
+            <AppIcon name="monitor" :size="26" color="#C6171E" />
           </view>
           <text class="action-label">设备监控</text>
         </view>
         <view class="action-item" @click="navigateTo('/pages/finance/index')">
           <view class="action-icon-wrap">
-            <AppIcon name="dollar" :size="26" color="#FFFFFF" />
+            <AppIcon name="dollar" :size="26" color="#C6171E" />
           </view>
           <text class="action-label">财务管理</text>
         </view>
         <view class="action-item" @click="navigateTo('/pages/user/list')">
           <view class="action-icon-wrap">
-            <AppIcon name="users" :size="26" color="#FFFFFF" />
+            <AppIcon name="users" :size="26" color="#C6171E" />
           </view>
           <text class="action-label">用户管理</text>
         </view>
         <view class="action-item" @click="navigateTo('/pages/setting/device-config')">
           <view class="action-icon-wrap">
-            <AppIcon name="settings" :size="26" color="#FFFFFF" />
+            <AppIcon name="settings" :size="26" color="#C6171E" />
           </view>
           <text class="action-label">设备配置</text>
         </view>
@@ -497,13 +497,12 @@ onPullDownRefresh(async () => {
   width: 88rpx;
   height: 88rpx;
   border-radius: 50%;
-  background: #C6171E;
+  background: #FFF0F0;
   display: flex;
   align-items: center;
   justify-content: center;
 }
 .action-item:active .action-icon-wrap {
-  background: #A81212;
   transform: scale(0.94);
 }
 .action-label {

+ 51 - 1
admin-web/src/router/route.ts

@@ -371,7 +371,57 @@ export const adminRoutes: Array<RouteRecordRaw> = [
                 ]
             },
 
-            // ==================== 6. 营销管理 ====================
+            // ==================== 6. 数据统计 ====================
+            {
+                path: '/statistics',
+                name: 'adminStatistics',
+                component: () => import('/@/layout/routerView/parent.vue'),
+                redirect: '/statistics/dailyStat',
+                meta: {
+                    title: '数据统计',
+                    isLink: '',
+                    isHide: false,
+                    isKeepAlive: true,
+                    isAffix: false,
+                    isIframe: false,
+                    icon: 'ele-DataAnalysis',
+                    perm: "dailyStat.list,monthStat.list",
+                },
+                children: [
+                    {
+                        path: '/statistics/dailyStat',
+                        name: 'adminStatisticsDailyStat',
+                        component: () => import('/@/views/admin/statistics/daily-stat.vue'),
+                        meta: {
+                            title: '日统计',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            icon: 'ele-DataLine',
+                            perm: "dailyStat.list",
+                        },
+                    },
+                    {
+                        path: '/statistics/monthStat',
+                        name: 'adminStatisticsMonthStat',
+                        component: () => import('/@/views/admin/statistics/month-stat.vue'),
+                        meta: {
+                            title: '月统计',
+                            isLink: '',
+                            isHide: false,
+                            isKeepAlive: true,
+                            isAffix: false,
+                            isIframe: false,
+                            icon: 'ele-PieChart',
+                            perm: "monthStat.list",
+                        },
+                    },
+                ]
+            },
+
+            // ==================== 7. 营销管理 ====================
             {
                 path: '/marketing',
                 name: 'adminMarketing',

+ 193 - 0
admin-web/src/views/admin/statistics/daily-stat.vue

@@ -0,0 +1,193 @@
+<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-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-date-picker
+            v-model="state.formQuery.startDate"
+            type="date"
+            placeholder="开始日期"
+            value-format="YYYY-MM-DD"
+            clearable
+            class="wd200 ml10"/>
+
+        <el-date-picker
+            v-model="state.formQuery.endDate"
+            type="date"
+            placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            clearable
+            class="wd200 ml10"/>
+
+        <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="handleSyncDailyStat">
+          <SvgIcon name="ele-RefreshRight"/>
+          重新同步
+        </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"
+            :show-overflow-tooltip="!field.fixed&&field.width>150">
+
+          <template #default="{row}">
+            <template v-if="['totalIncome','totalRefund','crossIncome','crossExpend','consumption'].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="adminStatisticsDailyStat">
+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: '',
+    startDate: '',
+    endDate: ''
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  },
+  columns: [
+    {label: '统计日期', width: 120, prop: 'statDate', fixed: 'left', resizable: true},
+    {label: '站点名称', width: 180, prop: 'stationName', resizable: true},
+    {label: '总收入(元)', width: 140, prop: 'totalIncome', resizable: true},
+    {label: '退款金额(元)', width: 140, prop: 'totalRefund', resizable: true},
+    {label: '跨店收入(元)', width: 140, prop: 'crossIncome', resizable: true},
+    {label: '跨店支出(元)', width: 140, prop: 'crossExpend', resizable: true},
+    {label: '消费金额(元)', width: 140, prop: 'consumption', resizable: true},
+    {label: '充值笔数', width: 100, prop: 'rechargeCount', resizable: true},
+    {label: '退款笔数', width: 100, prop: 'refundCount', resizable: true},
+    {label: '注册人数', width: 100, prop: 'registrations', resizable: true},
+    {label: '活跃用户数', width: 110, prop: 'activeUserCount', resizable: true},
+    {label: '订单数量', width: 100, prop: 'ordersCount', resizable: true},
+    {label: '创建时间', width: 180, prop: 'createTime', resizable: true},
+  ],
+})
+
+onMounted(() => {
+  // 默认查询昨天数据
+  const yesterday = new Date();
+  yesterday.setDate(yesterday.getDate() - 1);
+  state.formQuery.startDate = yesterday.toISOString().split('T')[0];
+  state.formQuery.endDate = yesterday.toISOString().split('T')[0];
+
+  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(`/daily-stat/list`, {...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 handleSyncDailyStat = () => {
+  Msg.confirm('确认重新同步该日期的统计数据吗?将同步所有站点的数据。', '重新同步日统计').then(() => {
+    Msg.showLoading('同步中...');
+    $body(`/daily-stat/sync`, {statDate: state.formQuery.startDate}).then(() => {
+      Msg.message("同步完成");
+      Msg.hideLoading();
+      loadData(true);
+    }).catch(e => {
+      Msg.hideLoading();
+    })
+  })
+};
+</script>

+ 182 - 0
admin-web/src/views/admin/statistics/month-stat.vue

@@ -0,0 +1,182 @@
+<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-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.statMonth"
+            placeholder="统计月份 如 2026-05"
+            clearable
+            class="wd200 ml10"/>
+
+        <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="handleSyncMonthStat">
+          <SvgIcon name="ele-RefreshRight"/>
+          重新同步
+        </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"
+            :show-overflow-tooltip="!field.fixed&&field.width>150">
+
+          <template #default="{row}">
+            <template v-if="['totalIncome','totalRefund','crossIncome','crossExpend','consumption','platformFee'].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="adminStatisticsMonthStat">
+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: '',
+    statMonth: ''
+  },
+  pageQuery: {
+    pageNum: 1,
+    pageSize: 10,
+    total: 0
+  },
+  tableData: {
+    height: 500,
+    data: [] as Array<any>,
+    loading: false
+  },
+  columns: [
+    {label: '统计月份', width: 120, prop: 'statMonth', fixed: 'left', resizable: true},
+    {label: '站点名称', width: 180, prop: 'stationName', resizable: true},
+    {label: '总收入(元)', width: 140, prop: 'totalIncome', resizable: true},
+    {label: '退款金额(元)', width: 140, prop: 'totalRefund', resizable: true},
+    {label: '跨店收入(元)', width: 140, prop: 'crossIncome', resizable: true},
+    {label: '跨店支出(元)', width: 140, prop: 'crossExpend', resizable: true},
+    {label: '消费金额(元)', width: 140, prop: 'consumption', resizable: true},
+    {label: '平台服务费(元)', width: 150, prop: 'platformFee', resizable: true},
+    {label: '充值笔数', width: 100, prop: 'rechargeCount', resizable: true},
+    {label: '退款笔数', width: 100, prop: 'refundCount', resizable: true},
+    {label: '注册人数', width: 100, prop: 'registrations', resizable: true},
+    {label: '活跃用户数', width: 110, prop: 'activeUserCount', resizable: true},
+    {label: '订单数量', width: 100, prop: 'ordersCount', resizable: true},
+    {label: '创建时间', width: 180, prop: 'createTime', resizable: true},
+  ],
+})
+
+onMounted(() => {
+  // 默认查询上月数据
+  const now = new Date();
+  const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+  state.formQuery.statMonth = lastMonth.getFullYear() + '-' + String(lastMonth.getMonth() + 1).padStart(2, '0');
+
+  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(`/month-stat/list`, {...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 handleSyncMonthStat = () => {
+  Msg.confirm('确认重新同步该月份的统计数据吗?将同步所有站点的数据。', '重新同步月统计').then(() => {
+    Msg.showLoading('同步中...');
+    $body(`/month-stat/sync`, {statMonth: state.formQuery.statMonth}).then(() => {
+      Msg.message("同步完成");
+      Msg.hideLoading();
+      loadData(true);
+    }).catch(e => {
+      Msg.hideLoading();
+    })
+  })
+};
+</script>

+ 29 - 5
car-wash-admin/src/main/java/com/kym/admin/controller/DailyStatController.java

@@ -1,12 +1,16 @@
 package com.kym.admin.controller;
 
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.kym.common.R;
+import com.kym.common.annotation.SysLog;
+import com.kym.entity.queryParams.DailyStatQueryParam;
+import com.kym.service.DailyStatService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
 
 /**
- * <p>
- * 日统计表 前端控制器
- * </p>
+ * 日统计 前端控制器
  *
  * @author skyline
  * @since 2025-02-19
@@ -15,4 +19,24 @@ import org.springframework.web.bind.annotation.RestController;
 @RequestMapping("/daily-stat")
 public class DailyStatController {
 
+    private final DailyStatService dailyStatService;
+
+    public DailyStatController(DailyStatService dailyStatService) {
+        this.dailyStatService = dailyStatService;
+    }
+
+    @SaCheckPermission("dailyStat.list")
+    @PostMapping("/list")
+    public R<?> list(@RequestBody DailyStatQueryParam params) {
+        return R.success(dailyStatService.listDailyStat(params));
+    }
+
+    @SaCheckPermission("dailyStat.sync")
+    @SysLog("手动同步日统计")
+    @PostMapping("/sync")
+    public R<?> sync(@RequestBody Map<String, String> body) {
+        String statDate = body.get("statDate");
+        dailyStatService.syncDailyStat(statDate);
+        return R.success();
+    }
 }

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

@@ -129,6 +129,16 @@ public class FinanceController {
         return R.success();
     }
 
+    /**
+     * 批量处理用户退款
+     */
+    @SysLog("批量处理用户微信退款")
+    @PostMapping("/batchWxRefund")
+    R<?> batchWxRefund(@RequestBody java.util.List<Long> refundLogIds) {
+        wxPayService.batchWxRefund(refundLogIds);
+        return R.success();
+    }
+
     /**
      * 退款记录列表
      *

+ 29 - 5
car-wash-admin/src/main/java/com/kym/admin/controller/MonthStatController.java

@@ -1,12 +1,16 @@
 package com.kym.admin.controller;
 
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.kym.common.R;
+import com.kym.common.annotation.SysLog;
+import com.kym.entity.queryParams.MonthStatQueryParam;
+import com.kym.service.MonthStatService;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
 
 /**
- * <p>
- * 日统计表 前端控制器
- * </p>
+ * 月统计 前端控制器
  *
  * @author skyline
  * @since 2025-04-03
@@ -15,4 +19,24 @@ import org.springframework.web.bind.annotation.RestController;
 @RequestMapping("/month-stat")
 public class MonthStatController {
 
+    private final MonthStatService monthStatService;
+
+    public MonthStatController(MonthStatService monthStatService) {
+        this.monthStatService = monthStatService;
+    }
+
+    @SaCheckPermission("monthStat.list")
+    @PostMapping("/list")
+    public R<?> list(@RequestBody MonthStatQueryParam params) {
+        return R.success(monthStatService.listMonthStat(params));
+    }
+
+    @SaCheckPermission("monthStat.sync")
+    @SysLog("手动同步月统计")
+    @PostMapping("/sync")
+    public R<?> sync(@RequestBody Map<String, String> body) {
+        String statMonth = body.get("statMonth");
+        monthStatService.syncMonthlyStat(statMonth);
+        return R.success();
+    }
 }

+ 11 - 76
car-wash-admin/src/main/java/com/kym/admin/jobs/StatJob.java

@@ -1,16 +1,13 @@
 package com.kym.admin.jobs;
 
-import cn.hutool.core.date.DateUtil;
-import com.kym.entity.DailyStat;
-import com.kym.entity.MonthStat;
-import com.kym.service.*;
+import com.kym.service.DailyStatService;
+import com.kym.service.MonthStatService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.util.ArrayList;
+import java.time.format.DateTimeFormatter;
 
 /**
  * 数据统计定时任务
@@ -19,96 +16,34 @@ import java.util.ArrayList;
 @Slf4j
 public class StatJob {
 
-    private final UserService userService;
-
-    private final WashOrderService washOrderService;
-
     private final DailyStatService dailyStatService;
-
     private final MonthStatService monthStatService;
 
-    private final SplitRecordService splitRecordService;
-
-    public StatJob(UserService userService, WashOrderService washOrderService, DailyStatService dailyStatService, MonthStatService monthStatService, SplitRecordService splitRecordService) {
-        this.userService = userService;
-        this.washOrderService = washOrderService;
+    public StatJob(DailyStatService dailyStatService, MonthStatService monthStatService) {
         this.dailyStatService = dailyStatService;
         this.monthStatService = monthStatService;
-        this.splitRecordService = splitRecordService;
     }
 
     /**
-     * 统计前一天的消费金额,注册人数,订单数量
+     * 统计前一天的日数据
      * 每天凌晨00:30执行一次
      */
     @Scheduled(cron = "0 30 0 * * ?")
     public void generateDailyStat() {
         log.info("执行站点日统计定时任务-开始");
-        LocalDate yesterday = LocalDate.now().minusDays(1);
-
-        // 各站点总订单金额,注册会员人数,订单数量
-        var amountMap = washOrderService.sumAmountByDate(yesterday);
-        var registerMap = userService.countDailyRegister(yesterday);
-        var ordersCountMap = washOrderService.countDailyOrders(yesterday);
-
-        // 各站点总收入
-        var incomeMap = splitRecordService.sumDailyIncome(yesterday);
-        // 各站点跨店收入
-        var crossIncomeMap = splitRecordService.sumDailyIncome(yesterday, Boolean.TRUE);
-        // 各站点跨店支出
-        var expendMap = splitRecordService.sumDailyExpend(yesterday);
-
-        var statList = new ArrayList<DailyStat>();
-        amountMap.forEach((k, v) -> {
-            DailyStat stat = new DailyStat();
-            stat.setStatDate(DateUtil.format(LocalDateTime.now().minusDays(1), "yyyy-MM-dd"));
-            stat.setStationId(k);
-            stat.setConsumption(v);
-            stat.setRegistrations(registerMap.getOrDefault(k, 0));
-            stat.setOrdersCount(ordersCountMap.getOrDefault(k, 0));
-            stat.setTotalIncome(incomeMap.getOrDefault(k, 0));
-            stat.setCrossIncome(crossIncomeMap.getOrDefault(k, 0));
-            stat.setCrossExpend(expendMap.getOrDefault(k, 0));
-            statList.add(stat);
-        });
-        dailyStatService.replaceBatch(statList);
+        var yesterday = LocalDate.now().minusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE);
+        dailyStatService.syncDailyStat(yesterday);
         log.info("执行站点日统计定时任务-结束");
     }
 
-
     /**
-     * 月统计,每月5日下午15:00启动,统计上月数据
+     * 月统计,每月5日下午15:00启动,统计上月数据
      */
     @Scheduled(cron = "0 0 15 5 * ?")
-    private void generateMonthStat() {
+    public void generateMonthStat() {
         log.info("执行站点月统计定时任务-开始");
-        var statMonth = LocalDate.now().minusMonths(1);
-        var amountMap = washOrderService.sumMonthAmount(statMonth);
-        var registerMap = userService.countMonthRegister(statMonth);
-        var ordersCountMap = washOrderService.countMonthOrders(statMonth);
-
-        // 各站点总收入
-        var incomeMap = splitRecordService.sumMonthIncome(statMonth);
-        // 各站点跨店收入
-        var crossIncomeMap = splitRecordService.sumMonthIncome(statMonth, Boolean.TRUE);
-        // 各站点跨店支出
-        var expendMap = splitRecordService.sumMonthExpend(statMonth);
-
-        var statList = new ArrayList<MonthStat>();
-        amountMap.forEach((k, v) -> {
-            MonthStat stat = new MonthStat();
-            stat.setStatMonth(DateUtil.format(LocalDateTime.now().minusMonths(1), "yyyy-MM"));
-            stat.setStationId(k);
-            stat.setConsumption(v);
-            stat.setRegistrations(registerMap.getOrDefault(k, 0));
-            stat.setOrdersCount(ordersCountMap.getOrDefault(k, 0));
-            stat.setTotalIncome(incomeMap.getOrDefault(k, 0));
-            stat.setCrossIncome(crossIncomeMap.getOrDefault(k, 0));
-            stat.setCrossExpend(expendMap.getOrDefault(k, 0));
-            statList.add(stat);
-        });
-        monthStatService.replaceBatch(statList);
+        var statMonth = LocalDate.now().minusMonths(1).format(DateTimeFormatter.ofPattern("yyyy-MM"));
+        monthStatService.syncMonthlyStat(statMonth);
         log.info("执行站点月统计定时任务-结束");
     }
-
 }

+ 21 - 0
car-wash-entity/src/main/java/com/kym/entity/DailyStat.java

@@ -45,6 +45,22 @@ public class DailyStat extends BaseEntity {
      * 跨店支出(分)
      */
     private Integer crossExpend;
+
+    /**
+     * 总退款金额(分)
+     */
+    private Integer totalRefund;
+
+    /**
+     * 充值笔数
+     */
+    private Integer rechargeCount;
+
+    /**
+     * 退款笔数
+     */
+    private Integer refundCount;
+
     /**
      * 消费金额(分)
      */
@@ -55,6 +71,11 @@ public class DailyStat extends BaseEntity {
      */
     private Integer registrations;
 
+    /**
+     * 活跃用户数(当日有订单的用户)
+     */
+    private Integer activeUserCount;
+
     /**
      * 订单数量
      */

+ 25 - 0
car-wash-entity/src/main/java/com/kym/entity/MonthStat.java

@@ -46,6 +46,26 @@ public class MonthStat extends BaseEntity {
      */
     private Integer crossExpend;
 
+    /**
+     * 总退款金额(分)
+     */
+    private Integer totalRefund;
+
+    /**
+     * 平台服务费(分)
+     */
+    private Integer platformFee;
+
+    /**
+     * 充值笔数
+     */
+    private Integer rechargeCount;
+
+    /**
+     * 退款笔数
+     */
+    private Integer refundCount;
+
     /**
      * 本店消费金额(分)
      */
@@ -56,6 +76,11 @@ public class MonthStat extends BaseEntity {
      */
     private Integer registrations;
 
+    /**
+     * 月活跃用户数
+     */
+    private Integer activeUserCount;
+
     /**
      * 订单数量
      */

+ 40 - 0
car-wash-entity/src/main/java/com/kym/entity/StatSyncLog.java

@@ -0,0 +1,40 @@
+package com.kym.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * 统计同步日志表
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+@TableName("t_stat_sync_log")
+public class StatSyncLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String TYPE_DAILY = "DAILY";
+    public static final String TYPE_MONTHLY = "MONTHLY";
+
+    public static final int STATUS_IN_PROGRESS = 0;
+    public static final int STATUS_SUCCESS = 1;
+    public static final int STATUS_FAILED = 2;
+
+    private String statType;
+    private String statKey;
+    private String stationId;
+    private Integer syncStatus;
+    private Long syncBy;
+    private String syncByName;
+    private String errorMsg;
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+}

+ 22 - 0
car-wash-entity/src/main/java/com/kym/entity/queryParams/DailyStatQueryParam.java

@@ -0,0 +1,22 @@
+package com.kym.entity.queryParams;
+
+import com.kym.entity.common.PageParams;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * 日统计查询参数
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class DailyStatQueryParam extends PageParams {
+
+    private String stationId;
+    private LocalDate startDate;
+    private LocalDate endDate;
+}

+ 19 - 0
car-wash-entity/src/main/java/com/kym/entity/queryParams/MonthStatQueryParam.java

@@ -0,0 +1,19 @@
+package com.kym.entity.queryParams;
+
+import com.kym.entity.common.PageParams;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 月统计查询参数
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class MonthStatQueryParam extends PageParams {
+
+    private String stationId;
+    private String statMonth;
+}

+ 22 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/DailyStatVo.java

@@ -0,0 +1,22 @@
+package com.kym.entity.vo;
+
+import com.kym.entity.DailyStat;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+/**
+ * 日统计 VO(带站点名称)
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+public class DailyStatVo extends DailyStat {
+
+    private static final long serialVersionUID = 1L;
+
+    private String stationName;
+}

+ 22 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/MonthStatVo.java

@@ -0,0 +1,22 @@
+package com.kym.entity.vo;
+
+import com.kym.entity.MonthStat;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+/**
+ * 月统计 VO(带站点名称)
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+public class MonthStatVo extends MonthStat {
+
+    private static final long serialVersionUID = 1L;
+
+    private String stationName;
+}

+ 189 - 0
car-wash-entity/src/main/resources/sql/v10_daily_monthly_stat.sql

@@ -0,0 +1,189 @@
+-- ====================================================
+-- 数据统计模块 V2 数据库变更脚本
+-- 扩展日/月统计表字段,新增同步日志表
+-- 发布日期:2026-06-11
+-- ====================================================
+
+-- ----------------------------
+-- 1. t_daily_stat 新增字段
+-- ----------------------------
+DROP PROCEDURE IF EXISTS add_daily_stat_columns;
+DELIMITER //
+CREATE PROCEDURE add_daily_stat_columns()
+BEGIN
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_daily_stat'
+          AND COLUMN_NAME = 'total_refund'
+    ) THEN
+        ALTER TABLE `t_daily_stat`
+            ADD COLUMN `total_refund` INT NOT NULL DEFAULT 0 COMMENT '总退款金额(分)' AFTER `total_income`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_daily_stat'
+          AND COLUMN_NAME = 'recharge_count'
+    ) THEN
+        ALTER TABLE `t_daily_stat`
+            ADD COLUMN `recharge_count` INT NOT NULL DEFAULT 0 COMMENT '充值笔数' AFTER `cross_expend`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_daily_stat'
+          AND COLUMN_NAME = 'refund_count'
+    ) THEN
+        ALTER TABLE `t_daily_stat`
+            ADD COLUMN `refund_count` INT NOT NULL DEFAULT 0 COMMENT '退款笔数' AFTER `recharge_count`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_daily_stat'
+          AND COLUMN_NAME = 'active_user_count'
+    ) THEN
+        ALTER TABLE `t_daily_stat`
+            ADD COLUMN `active_user_count` INT NOT NULL DEFAULT 0 COMMENT '活跃用户数(当日有订单的用户)' AFTER `registrations`;
+    END IF;
+
+    -- 添加唯一索引(支持 REPLACE INTO upsert)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_daily_stat'
+          AND INDEX_NAME = 'uk_station_date'
+    ) THEN
+        ALTER TABLE `t_daily_stat`
+            ADD UNIQUE INDEX `uk_station_date` (`station_id`, `stat_date`);
+    END IF;
+END //
+DELIMITER ;
+CALL add_daily_stat_columns();
+DROP PROCEDURE IF EXISTS add_daily_stat_columns;
+
+-- ----------------------------
+-- 2. t_month_stat 新增字段
+-- ----------------------------
+DROP PROCEDURE IF EXISTS add_month_stat_columns;
+DELIMITER //
+CREATE PROCEDURE add_month_stat_columns()
+BEGIN
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND COLUMN_NAME = 'total_refund'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD COLUMN `total_refund` INT NOT NULL DEFAULT 0 COMMENT '总退款金额(分)' AFTER `total_income`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND COLUMN_NAME = 'platform_fee'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD COLUMN `platform_fee` INT NOT NULL DEFAULT 0 COMMENT '平台服务费(分)' AFTER `cross_expend`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND COLUMN_NAME = 'recharge_count'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD COLUMN `recharge_count` INT NOT NULL DEFAULT 0 COMMENT '充值笔数' AFTER `platform_fee`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND COLUMN_NAME = 'refund_count'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD COLUMN `refund_count` INT NOT NULL DEFAULT 0 COMMENT '退款笔数' AFTER `recharge_count`;
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND COLUMN_NAME = 'active_user_count'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD COLUMN `active_user_count` INT NOT NULL DEFAULT 0 COMMENT '月活跃用户数' AFTER `registrations`;
+    END IF;
+
+    -- 添加唯一索引(支持 REPLACE INTO upsert)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_month_stat'
+          AND INDEX_NAME = 'uk_station_month'
+    ) THEN
+        ALTER TABLE `t_month_stat`
+            ADD UNIQUE INDEX `uk_station_month` (`station_id`, `stat_month`);
+    END IF;
+END //
+DELIMITER ;
+CALL add_month_stat_columns();
+DROP PROCEDURE IF EXISTS add_month_stat_columns;
+
+-- ----------------------------
+-- 3. 新增统计同步日志表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `t_stat_sync_log` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `stat_type` VARCHAR(10) NOT NULL COMMENT '统计类型:DAILY, MONTHLY',
+    `stat_key` VARCHAR(10) NOT NULL COMMENT '统计键:日期(YYYY-MM-DD)或月份(YYYY-MM)',
+    `station_id` VARCHAR(64) NOT NULL COMMENT '站点ID',
+    `sync_status` TINYINT NOT NULL DEFAULT 0 COMMENT '同步状态:0-进行中,1-成功,2-失败',
+    `sync_by` BIGINT DEFAULT NULL COMMENT '操作人ID(系统自动为NULL)',
+    `sync_by_name` VARCHAR(50) DEFAULT NULL COMMENT '操作人名称',
+    `error_msg` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
+    `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
+    `end_time` DATETIME 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_stat_type_key` (`stat_type`, `stat_key`),
+    KEY `idx_station_id` (`station_id`),
+    KEY `idx_sync_status` (`sync_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统计同步日志表';
+
+-- ----------------------------
+-- 4. 新增权限
+-- ----------------------------
+-- 先确保父菜单"数据统计"权限存在
+INSERT INTO `t_permission` (`name`, `value`, `pid`, `weight`)
+SELECT '数据统计', 'statistics', 0, 6
+WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'statistics');
+
+-- 获取"数据统计"权限ID并插入子权限
+-- 注意:如果上面INSERT是新插入的,LAST_INSERT_ID()会返回新ID;如果已存在,需要手动查询
+SET @statistics_pid = (SELECT id FROM `t_permission` WHERE `value` = 'statistics' LIMIT 1);
+
+INSERT INTO `t_permission` (`name`, `value`, `pid`, `weight`)
+SELECT '日统计列表', 'dailyStat.list', @statistics_pid, 1
+WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'dailyStat.list');
+
+INSERT INTO `t_permission` (`name`, `value`, `pid`, `weight`)
+SELECT '日统计同步', 'dailyStat.sync', @statistics_pid, 2
+WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'dailyStat.sync');
+
+INSERT INTO `t_permission` (`name`, `value`, `pid`, `weight`)
+SELECT '月统计列表', 'monthStat.list', @statistics_pid, 3
+WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'monthStat.list');
+
+INSERT INTO `t_permission` (`name`, `value`, `pid`, `weight`)
+SELECT '月统计同步', 'monthStat.sync', @statistics_pid, 4
+WHERE NOT EXISTS (SELECT 1 FROM `t_permission` WHERE `value` = 'monthStat.sync');

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

@@ -0,0 +1,13 @@
+package com.kym.mapper;
+
+import com.kym.entity.StatSyncLog;
+import com.kym.mapper.mybatisplus.MyBaseMapper;
+
+/**
+ * 统计同步日志 Mapper
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+public interface StatSyncLogMapper extends MyBaseMapper<StatSyncLog> {
+}

+ 10 - 1
car-wash-mapper/src/main/resources/mappers/DailyStatMapper.xml

@@ -5,14 +5,23 @@
     <!-- 通用查询映射结果 -->
     <resultMap id="BaseResultMap" type="com.kym.entity.DailyStat">
         <result column="stat_date" property="statDate" />
+        <result column="station_id" property="stationId" />
+        <result column="total_income" property="totalIncome" />
+        <result column="total_refund" property="totalRefund" />
+        <result column="cross_income" property="crossIncome" />
+        <result column="cross_expend" property="crossExpend" />
+        <result column="recharge_count" property="rechargeCount" />
+        <result column="refund_count" property="refundCount" />
         <result column="consumption" property="consumption" />
         <result column="registrations" property="registrations" />
+        <result column="active_user_count" property="activeUserCount" />
         <result column="orders_count" property="ordersCount" />
     </resultMap>
 
     <!-- 通用查询结果列 -->
     <sql id="Base_Column_List">
-        stat_date, consumption, registrations, orders_count
+        stat_date, station_id, total_income, total_refund, cross_income, cross_expend,
+        recharge_count, refund_count, consumption, registrations, active_user_count, orders_count
     </sql>
 
 </mapper>

+ 7 - 1
car-wash-mapper/src/main/resources/mappers/MonthStatMapper.xml

@@ -7,16 +7,22 @@
         <result column="stat_month" property="statMonth" />
         <result column="station_id" property="stationId" />
         <result column="total_income" property="totalIncome" />
+        <result column="total_refund" property="totalRefund" />
         <result column="cross_income" property="crossIncome" />
         <result column="cross_expend" property="crossExpend" />
+        <result column="platform_fee" property="platformFee" />
+        <result column="recharge_count" property="rechargeCount" />
+        <result column="refund_count" property="refundCount" />
         <result column="consumption" property="consumption" />
         <result column="registrations" property="registrations" />
+        <result column="active_user_count" property="activeUserCount" />
         <result column="orders_count" property="ordersCount" />
     </resultMap>
 
     <!-- 通用查询结果列 -->
     <sql id="Base_Column_List">
-        stat_month, station_id, total_income, cross_income, cross_expend, consumption, registrations, orders_count
+        stat_month, station_id, total_income, total_refund, cross_income, cross_expend,
+        platform_fee, recharge_count, refund_count, consumption, registrations, active_user_count, orders_count
     </sql>
 
 </mapper>

+ 17 - 0
car-wash-mapper/src/main/resources/mappers/StatSyncLogMapper.xml

@@ -0,0 +1,17 @@
+<?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.StatSyncLogMapper">
+
+    <resultMap id="BaseResultMap" type="com.kym.entity.StatSyncLog">
+        <result column="stat_type" property="statType" />
+        <result column="stat_key" property="statKey" />
+        <result column="station_id" property="stationId" />
+        <result column="sync_status" property="syncStatus" />
+        <result column="sync_by" property="syncBy" />
+        <result column="sync_by_name" property="syncByName" />
+        <result column="error_msg" property="errorMsg" />
+        <result column="start_time" property="startTime" />
+        <result column="end_time" property="endTime" />
+    </resultMap>
+
+</mapper>

+ 6 - 2
car-wash-service/src/main/java/com/kym/service/DailyStatService.java

@@ -1,16 +1,20 @@
 package com.kym.service;
 
 import com.kym.entity.DailyStat;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.DailyStatQueryParam;
+import com.kym.entity.vo.DailyStatVo;
 import com.kym.service.mybatisplus.MyBaseService;
 
 /**
- * <p>
  * 日统计表 服务类
- * </p>
  *
  * @author skyline
  * @since 2025-02-19
  */
 public interface DailyStatService extends MyBaseService<DailyStat> {
 
+    PageBean<DailyStatVo> listDailyStat(DailyStatQueryParam params);
+
+    void syncDailyStat(String statDate);
 }

+ 7 - 3
car-wash-service/src/main/java/com/kym/service/MonthStatService.java

@@ -1,16 +1,20 @@
 package com.kym.service;
 
 import com.kym.entity.MonthStat;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.MonthStatQueryParam;
+import com.kym.entity.vo.MonthStatVo;
 import com.kym.service.mybatisplus.MyBaseService;
 
 /**
- * <p>
- * 日统计表 服务类
- * </p>
+ * 月统计表 服务类
  *
  * @author skyline
  * @since 2025-04-03
  */
 public interface MonthStatService extends MyBaseService<MonthStat> {
 
+    PageBean<MonthStatVo> listMonthStat(MonthStatQueryParam params);
+
+    void syncMonthlyStat(String statMonth);
 }

+ 12 - 0
car-wash-service/src/main/java/com/kym/service/SplitRecordService.java

@@ -27,4 +27,16 @@ public interface SplitRecordService extends MyBaseService<SplitRecord> {
     Map<String, Integer> sumDailyExpend(LocalDate statDay, Boolean... isCross);
 
     Map<String, Integer> sumMonthExpend(LocalDate statDay, Boolean... isCross);
+
+    Map<String, Integer> sumDailyRefund(LocalDate statDay);
+
+    Map<String, Integer> sumMonthRefund(LocalDate statDay);
+
+    Map<String, Integer> countDailyRecharge(LocalDate statDay);
+
+    Map<String, Integer> countMonthRecharge(LocalDate statDay);
+
+    Map<String, Integer> countDailyRefund(LocalDate statDay);
+
+    Map<String, Integer> countMonthRefund(LocalDate statDay);
 }

+ 13 - 0
car-wash-service/src/main/java/com/kym/service/StatSyncLogService.java

@@ -0,0 +1,13 @@
+package com.kym.service;
+
+import com.kym.entity.StatSyncLog;
+import com.kym.service.mybatisplus.MyBaseService;
+
+/**
+ * 统计同步日志 服务类
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+public interface StatSyncLogService extends MyBaseService<StatSyncLog> {
+}

+ 4 - 0
car-wash-service/src/main/java/com/kym/service/WashOrderService.java

@@ -54,6 +54,10 @@ public interface WashOrderService extends MyBaseService<WashOrder> {
 
     Map<String, Integer> countMonthOrders(LocalDate statDay);
 
+    Map<String, Integer> countDailyActiveUsers(LocalDate statDay);
+
+    Map<String, Integer> countMonthActiveUsers(LocalDate statDay);
+
     WashOrder getOrderInProgressByUserId(long userId);
 
     List<StationTrendVo> stationTrend(StatQueryParam params);

+ 148 - 3
car-wash-service/src/main/java/com/kym/service/impl/DailyStatServiceImpl.java

@@ -1,20 +1,165 @@
 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.DailyStat;
+import com.kym.entity.StatSyncLog;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.DailyStatQueryParam;
+import com.kym.entity.vo.DailyStatVo;
 import com.kym.mapper.DailyStatMapper;
-import com.kym.service.DailyStatService;
+import com.kym.service.*;
+import com.kym.service.cache.KymCache;
 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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
- * <p>
  * 日统计表 服务实现类
- * </p>
  *
  * @author skyline
  * @since 2025-02-19
  */
+@Slf4j
 @Service
 public class DailyStatServiceImpl extends MyBaseServiceImpl<DailyStatMapper, DailyStat> implements DailyStatService {
 
+    private final WashOrderService washOrderService;
+    private final UserService userService;
+    private final SplitRecordService splitRecordService;
+    private final WashStationService washStationService;
+    private final StatSyncLogService statSyncLogService;
+
+    public DailyStatServiceImpl(WashOrderService washOrderService, UserService userService,
+                                SplitRecordService splitRecordService, WashStationService washStationService,
+                                StatSyncLogService statSyncLogService) {
+        this.washOrderService = washOrderService;
+        this.userService = userService;
+        this.splitRecordService = splitRecordService;
+        this.washStationService = washStationService;
+        this.statSyncLogService = statSyncLogService;
+    }
+
+    @Override
+    public PageBean<DailyStatVo> listDailyStat(DailyStatQueryParam params) {
+        var query = lambdaQuery()
+                .eq(CommUtil.isNotEmptyAndNull(params.getStationId()),
+                        DailyStat::getStationId, params.getStationId())
+                .ge(params.getStartDate() != null,
+                        DailyStat::getStatDate, params.getStartDate().format(DateTimeFormatter.ISO_LOCAL_DATE))
+                .le(params.getEndDate() != null,
+                        DailyStat::getStatDate, params.getEndDate().format(DateTimeFormatter.ISO_LOCAL_DATE))
+                .orderByDesc(DailyStat::getStatDate);
+
+        var page = PageHelper.startPage(params.getPageNum(), params.getPageSize());
+        var list = query.list();
+
+        var voList = list.stream().map(item -> {
+            var vo = new DailyStatVo();
+            BeanUtils.copyProperties(item, vo);
+            vo.setStationName(KymCache.INSTANCE.getStationNameById(item.getStationId()));
+            return vo;
+        }).toList();
+
+        var bean = new PageBean<>(voList);
+        bean.setPages(page.getPages());
+        bean.setPageNum(page.getPageNum());
+        bean.setPageSize(page.getPageSize());
+        bean.setTotal(page.getTotal());
+        return bean;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncDailyStat(String statDate) {
+        LocalDate statDay = LocalDate.parse(statDate, DateTimeFormatter.ISO_LOCAL_DATE);
+        log.info("手动同步日统计: {}", statDate);
+
+        // 一次性聚合所有数据
+        var amountMap = washOrderService.sumAmountByDate(statDay);
+        var registerMap = userService.countDailyRegister(statDay);
+        var ordersCountMap = washOrderService.countDailyOrders(statDay);
+        var incomeMap = splitRecordService.sumDailyIncome(statDay);
+        var crossIncomeMap = splitRecordService.sumDailyIncome(statDay, Boolean.TRUE);
+        var expendMap = splitRecordService.sumDailyExpend(statDay);
+        var refundMap = splitRecordService.sumDailyRefund(statDay);
+        var rechargeCountMap = splitRecordService.countDailyRecharge(statDay);
+        var refundCountMap = splitRecordService.countDailyRefund(statDay);
+        var activeUserMap = washOrderService.countDailyActiveUsers(statDay);
+
+        // 收集所有出现过的站点
+        var allStationIds = Stream.of(amountMap, registerMap, ordersCountMap, incomeMap,
+                        crossIncomeMap, expendMap, refundMap, rechargeCountMap, refundCountMap, activeUserMap)
+                .flatMap(m -> m.keySet().stream())
+                .collect(Collectors.toSet());
+
+        long operatorId = 0;
+        String operatorName = "系统";
+        try {
+            operatorId = StpUtil.getLoginIdAsLong();
+            operatorName = StpUtil.getLoginIdAsString();
+        } catch (Exception ignored) {
+        }
+
+        var now = LocalDateTime.now();
+        var statList = new ArrayList<DailyStat>();
+
+        for (var stationId : allStationIds) {
+            try {
+                var stat = new DailyStat();
+                stat.setStatDate(statDate);
+                stat.setStationId(stationId);
+                stat.setConsumption(amountMap.getOrDefault(stationId, 0));
+                stat.setRegistrations(registerMap.getOrDefault(stationId, 0));
+                stat.setOrdersCount(ordersCountMap.getOrDefault(stationId, 0));
+                stat.setTotalIncome(incomeMap.getOrDefault(stationId, 0));
+                stat.setCrossIncome(crossIncomeMap.getOrDefault(stationId, 0));
+                stat.setCrossExpend(expendMap.getOrDefault(stationId, 0));
+                stat.setTotalRefund(refundMap.getOrDefault(stationId, 0));
+                stat.setRechargeCount(rechargeCountMap.getOrDefault(stationId, 0));
+                stat.setRefundCount(refundCountMap.getOrDefault(stationId, 0));
+                stat.setActiveUserCount(activeUserMap.getOrDefault(stationId, 0));
+                statList.add(stat);
+
+                var syncLog = new StatSyncLog()
+                        .setStatType(StatSyncLog.TYPE_DAILY)
+                        .setStatKey(statDate)
+                        .setStationId(stationId)
+                        .setSyncStatus(StatSyncLog.STATUS_SUCCESS)
+                        .setSyncBy(operatorId)
+                        .setSyncByName(operatorName)
+                        .setStartTime(now)
+                        .setEndTime(LocalDateTime.now());
+                statSyncLogService.save(syncLog);
+            } catch (Exception e) {
+                log.error("同步日统计失败: statDate={}, stationId={}", statDate, stationId, e);
+                var syncLog = new StatSyncLog()
+                        .setStatType(StatSyncLog.TYPE_DAILY)
+                        .setStatKey(statDate)
+                        .setStationId(stationId)
+                        .setSyncStatus(StatSyncLog.STATUS_FAILED)
+                        .setSyncBy(operatorId)
+                        .setSyncByName(operatorName)
+                        .setErrorMsg(e.getMessage())
+                        .setStartTime(now)
+                        .setEndTime(LocalDateTime.now());
+                statSyncLogService.save(syncLog);
+            }
+        }
+
+        if (!statList.isEmpty()) {
+            replaceBatch(statList);
+        }
+        log.info("日统计同步完成: {}, 站点数: {}", statDate, statList.size());
+    }
 }

+ 161 - 4
car-wash-service/src/main/java/com/kym/service/impl/MonthStatServiceImpl.java

@@ -1,20 +1,177 @@
 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.MonthStat;
+import com.kym.entity.SettlementRecord;
+import com.kym.entity.StatSyncLog;
+import com.kym.entity.common.PageBean;
+import com.kym.entity.queryParams.MonthStatQueryParam;
+import com.kym.entity.vo.MonthStatVo;
 import com.kym.mapper.MonthStatMapper;
-import com.kym.service.MonthStatService;
+import com.kym.service.*;
+import com.kym.service.cache.KymCache;
 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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
- * <p>
- * 日统计表 服务实现类
- * </p>
+ * 月统计表 服务实现类
  *
  * @author skyline
  * @since 2025-04-03
  */
+@Slf4j
 @Service
 public class MonthStatServiceImpl extends MyBaseServiceImpl<MonthStatMapper, MonthStat> implements MonthStatService {
 
+    private final WashOrderService washOrderService;
+    private final UserService userService;
+    private final SplitRecordService splitRecordService;
+    private final WashStationService washStationService;
+    private final StatSyncLogService statSyncLogService;
+    private final SettlementService settlementService;
+
+    public MonthStatServiceImpl(WashOrderService washOrderService, UserService userService,
+                                SplitRecordService splitRecordService, WashStationService washStationService,
+                                StatSyncLogService statSyncLogService, SettlementService settlementService) {
+        this.washOrderService = washOrderService;
+        this.userService = userService;
+        this.splitRecordService = splitRecordService;
+        this.washStationService = washStationService;
+        this.statSyncLogService = statSyncLogService;
+        this.settlementService = settlementService;
+    }
+
+    @Override
+    public PageBean<MonthStatVo> listMonthStat(MonthStatQueryParam params) {
+        var query = lambdaQuery()
+                .eq(CommUtil.isNotEmptyAndNull(params.getStationId()),
+                        MonthStat::getStationId, params.getStationId())
+                .eq(CommUtil.isNotEmptyAndNull(params.getStatMonth()),
+                        MonthStat::getStatMonth, params.getStatMonth())
+                .orderByDesc(MonthStat::getStatMonth);
+
+        var page = PageHelper.startPage(params.getPageNum(), params.getPageSize());
+        var list = query.list();
+
+        var voList = list.stream().map(item -> {
+            var vo = new MonthStatVo();
+            BeanUtils.copyProperties(item, vo);
+            vo.setStationName(KymCache.INSTANCE.getStationNameById(item.getStationId()));
+            return vo;
+        }).toList();
+
+        var bean = new PageBean<>(voList);
+        bean.setPages(page.getPages());
+        bean.setPageNum(page.getPageNum());
+        bean.setPageSize(page.getPageSize());
+        bean.setTotal(page.getTotal());
+        return bean;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncMonthlyStat(String statMonth) {
+        var yearMonth = YearMonth.parse(statMonth);
+        var statDay = yearMonth.atDay(1);
+        log.info("手动同步月统计: {}", statMonth);
+
+        Map<String, Integer> amountMap = washOrderService.sumMonthAmount(statDay);
+        Map<String, Integer> registerMap = userService.countMonthRegister(statDay);
+        Map<String, Integer> ordersCountMap = washOrderService.countMonthOrders(statDay);
+        Map<String, Integer> incomeMap = splitRecordService.sumMonthIncome(statDay);
+        Map<String, Integer> crossIncomeMap = splitRecordService.sumMonthIncome(statDay, Boolean.TRUE);
+        Map<String, Integer> expendMap = splitRecordService.sumMonthExpend(statDay);
+        Map<String, Integer> refundMap = splitRecordService.sumMonthRefund(statDay);
+        Map<String, Integer> rechargeCountMap = splitRecordService.countMonthRecharge(statDay);
+        Map<String, Integer> refundCountMap = splitRecordService.countMonthRefund(statDay);
+        Map<String, Integer> activeUserMap = washOrderService.countMonthActiveUsers(statDay);
+
+        var platformFeeMap = settlementService.lambdaQuery()
+                .eq(SettlementRecord::getSettlementPeriod, statMonth)
+                .list()
+                .stream()
+                .collect(Collectors.toMap(
+                        SettlementRecord::getStationId,
+                        SettlementRecord::getPlatformFee,
+                        Integer::sum));
+
+        var allStationIds = Stream.of(amountMap, registerMap, ordersCountMap, incomeMap,
+                        crossIncomeMap, expendMap, refundMap, rechargeCountMap, refundCountMap,
+                        activeUserMap, platformFeeMap)
+                .flatMap(m -> m.keySet().stream())
+                .collect(Collectors.toSet());
+
+        long operatorId = 0;
+        String operatorName = "系统";
+        try {
+            operatorId = StpUtil.getLoginIdAsLong();
+            operatorName = StpUtil.getLoginIdAsString();
+        } catch (Exception ignored) {
+        }
+
+        var now = LocalDateTime.now();
+        var statList = new ArrayList<MonthStat>();
+
+        for (var stationId : allStationIds) {
+            try {
+                var stat = new MonthStat();
+                stat.setStatMonth(statMonth);
+                stat.setStationId(stationId);
+                stat.setConsumption(amountMap.getOrDefault(stationId, 0));
+                stat.setRegistrations(registerMap.getOrDefault(stationId, 0));
+                stat.setOrdersCount(ordersCountMap.getOrDefault(stationId, 0));
+                stat.setTotalIncome(incomeMap.getOrDefault(stationId, 0));
+                stat.setCrossIncome(crossIncomeMap.getOrDefault(stationId, 0));
+                stat.setCrossExpend(expendMap.getOrDefault(stationId, 0));
+                stat.setTotalRefund(refundMap.getOrDefault(stationId, 0));
+                stat.setPlatformFee(platformFeeMap.getOrDefault(stationId, 0));
+                stat.setRechargeCount(rechargeCountMap.getOrDefault(stationId, 0));
+                stat.setRefundCount(refundCountMap.getOrDefault(stationId, 0));
+                stat.setActiveUserCount(activeUserMap.getOrDefault(stationId, 0));
+                statList.add(stat);
+
+                var syncLog = new StatSyncLog()
+                        .setStatType(StatSyncLog.TYPE_MONTHLY)
+                        .setStatKey(statMonth)
+                        .setStationId(stationId)
+                        .setSyncStatus(StatSyncLog.STATUS_SUCCESS)
+                        .setSyncBy(operatorId)
+                        .setSyncByName(operatorName)
+                        .setStartTime(now)
+                        .setEndTime(LocalDateTime.now());
+                statSyncLogService.save(syncLog);
+            } catch (Exception e) {
+                log.error("同步月统计失败: statMonth={}, stationId={}", statMonth, stationId, e);
+                var syncLog = new StatSyncLog()
+                        .setStatType(StatSyncLog.TYPE_MONTHLY)
+                        .setStatKey(statMonth)
+                        .setStationId(stationId)
+                        .setSyncStatus(StatSyncLog.STATUS_FAILED)
+                        .setSyncBy(operatorId)
+                        .setSyncByName(operatorName)
+                        .setErrorMsg(e.getMessage())
+                        .setStartTime(now)
+                        .setEndTime(LocalDateTime.now());
+                statSyncLogService.save(syncLog);
+            }
+        }
+
+        if (!statList.isEmpty()) {
+            replaceBatch(statList);
+        }
+        log.info("月统计同步完成: {}, 站点数: {}", statMonth, statList.size());
+    }
 }

+ 54 - 0
car-wash-service/src/main/java/com/kym/service/impl/SplitRecordServiceImpl.java

@@ -119,6 +119,60 @@ public class SplitRecordServiceImpl extends MyBaseServiceImpl<SplitRecordMapper,
         return sumStat(Boolean.FALSE, statDay, SplitRecord::getFromStationId, isCross);
     }
 
+    @Override
+    public Map<String, Integer> sumDailyRefund(LocalDate statDay) {
+        return sumByType(Boolean.TRUE, statDay, SplitRecord::getFromStationId, SplitRecord.TYPE_REFUND);
+    }
+
+    @Override
+    public Map<String, Integer> sumMonthRefund(LocalDate statDay) {
+        return sumByType(Boolean.FALSE, statDay, SplitRecord::getFromStationId, SplitRecord.TYPE_REFUND);
+    }
+
+    @Override
+    public Map<String, Integer> countDailyRecharge(LocalDate statDay) {
+        return countByType(Boolean.TRUE, statDay, SplitRecord::getToStationId, SplitRecord.TYPE_RECHARGE);
+    }
+
+    @Override
+    public Map<String, Integer> countMonthRecharge(LocalDate statDay) {
+        return countByType(Boolean.FALSE, statDay, SplitRecord::getToStationId, SplitRecord.TYPE_RECHARGE);
+    }
+
+    @Override
+    public Map<String, Integer> countDailyRefund(LocalDate statDay) {
+        return countByType(Boolean.TRUE, statDay, SplitRecord::getFromStationId, SplitRecord.TYPE_REFUND);
+    }
+
+    @Override
+    public Map<String, Integer> countMonthRefund(LocalDate statDay) {
+        return countByType(Boolean.FALSE, statDay, SplitRecord::getFromStationId, SplitRecord.TYPE_REFUND);
+    }
+
+    private Map<String, Integer> sumByType(Boolean isDaily, LocalDate statDay, Function<SplitRecord, String> classifier, Integer type) {
+        LocalDateTime startTime = isDaily ? getStartOfDay(statDay) : getStartOfMonth(statDay);
+        LocalDateTime endTime = isDaily ? getEndOfDay(statDay) : getEndOfMonth(statDay);
+        return lambdaQuery()
+                .ge(SplitRecord::getCreateTime, startTime)
+                .lt(SplitRecord::getCreateTime, endTime)
+                .eq(SplitRecord::getType, type)
+                .list()
+                .stream()
+                .collect(Collectors.toMap(classifier, SplitRecord::getAmount, Integer::sum));
+    }
+
+    private Map<String, Integer> countByType(Boolean isDaily, LocalDate statDay, Function<SplitRecord, String> classifier, Integer type) {
+        LocalDateTime startTime = isDaily ? getStartOfDay(statDay) : getStartOfMonth(statDay);
+        LocalDateTime endTime = isDaily ? getEndOfDay(statDay) : getEndOfMonth(statDay);
+        return lambdaQuery()
+                .eq(SplitRecord::getType, type)
+                .ge(SplitRecord::getCreateTime, startTime)
+                .lt(SplitRecord::getCreateTime, endTime)
+                .list()
+                .stream()
+                .collect(Collectors.groupingBy(classifier, Collectors.summingInt(o -> 1)));
+    }
+
     /**
      * 公共统计方法
      *

+ 17 - 0
car-wash-service/src/main/java/com/kym/service/impl/StatSyncLogServiceImpl.java

@@ -0,0 +1,17 @@
+package com.kym.service.impl;
+
+import com.kym.entity.StatSyncLog;
+import com.kym.mapper.StatSyncLogMapper;
+import com.kym.service.StatSyncLogService;
+import com.kym.service.mybatisplus.MyBaseServiceImpl;
+import org.springframework.stereotype.Service;
+
+/**
+ * 统计同步日志 服务实现类
+ *
+ * @author skyline
+ * @since 2026-06-11
+ */
+@Service
+public class StatSyncLogServiceImpl extends MyBaseServiceImpl<StatSyncLogMapper, StatSyncLog> implements StatSyncLogService {
+}

+ 24 - 0
car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java

@@ -465,6 +465,30 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
         return list(wrapper).stream().collect(Collectors.groupingBy(WashOrder::getStationId, Collectors.summingInt(o -> 1)));
     }
 
+    @Override
+    public Map<String, Integer> countDailyActiveUsers(LocalDate statDay) {
+        LambdaQueryWrapper<WashOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.select(WashOrder::getUserId, WashOrder::getStationId);
+        wrapper.ge(WashOrder::getStartTime, LocalDateTime.of(statDay, LocalTime.MIN));
+        wrapper.lt(WashOrder::getStartTime, LocalDateTime.of(statDay, LocalTime.MAX));
+        wrapper.groupBy(WashOrder::getStationId, WashOrder::getUserId);
+        return list(wrapper).stream()
+                .collect(Collectors.groupingBy(WashOrder::getStationId, Collectors.summingInt(o -> 1)));
+    }
+
+    @Override
+    public Map<String, Integer> countMonthActiveUsers(LocalDate statDay) {
+        var startTime = statDay.with(TemporalAdjusters.firstDayOfMonth()).atTime(LocalTime.MIN);
+        var endTime = statDay.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX);
+        LambdaQueryWrapper<WashOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.select(WashOrder::getUserId, WashOrder::getStationId);
+        wrapper.ge(WashOrder::getStartTime, startTime);
+        wrapper.lt(WashOrder::getStartTime, endTime);
+        wrapper.groupBy(WashOrder::getStationId, WashOrder::getUserId);
+        return list(wrapper).stream()
+                .collect(Collectors.groupingBy(WashOrder::getStationId, Collectors.summingInt(o -> 1)));
+    }
+
     @Override
     public WashOrder getOrderInProgressByUserId(long userId) {
         return lambdaQuery()

+ 2 - 0
car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java

@@ -18,6 +18,8 @@ public interface WxPayService {
 
     void wxRefund(long refundLogId);
 
+    void batchWxRefund(java.util.List<Long> refundLogIds);
+
     Refund queryByOutRefundNo(String outRefundNo);
 
     @SneakyThrows

+ 25 - 0
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -55,6 +55,7 @@ import java.math.RoundingMode;
 import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -643,6 +644,30 @@ public class WxPayServiceImpl implements WxPayService {
         }
     }
 
+    @Override
+    public void batchWxRefund(List<Long> refundLogIds) {
+        if (refundLogIds == null || refundLogIds.isEmpty()) {
+            throw new BusinessException("退款记录ID不能为空");
+        }
+        StringBuilder errors = new StringBuilder();
+        int successCount = 0;
+        for (Long refundLogId : refundLogIds) {
+            try {
+                wxRefund(refundLogId);
+                successCount++;
+            } catch (Exception e) {
+                LOGGER.error("批量退款处理失败,退款记录ID:{},错误:{}", refundLogId, e.getMessage());
+                errors.append("退款记录").append(refundLogId).append("处理失败:").append(e.getMessage()).append(";");
+            }
+        }
+        if (successCount == 0 && errors.length() > 0) {
+            throw new BusinessException(errors.toString());
+        }
+        if (errors.length() > 0) {
+            LOGGER.info("批量退款部分成功:成功{}笔,失败原因:{}", successCount, errors);
+        }
+    }
+
     /**
      * 查询单笔退款(通过商户退款单号)
      *