Jelajahi Sumber

feat(butler): add cfc-web butler admin UI + ButlerCommissionService + scheduled settlement

Xiaogang Liao 2 bulan lalu
induk
melakukan
0ad1ac5806

+ 226 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerCommissionService.java

@@ -0,0 +1,226 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ButlerCommissionSettlement;
+import com.etotem.cfc.entity.ButlerProfile;
+import com.etotem.cfc.entity.ButlerServiceRecord;
+import com.etotem.cfc.entity.SysConfig;
+import com.etotem.cfc.mapper.ButlerCommissionSettlementMapper;
+import com.etotem.cfc.mapper.ButlerProfileMapper;
+import com.etotem.cfc.mapper.ButlerServiceRecordMapper;
+import com.etotem.cfc.mapper.SysConfigMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class ButlerCommissionService {
+
+    private static final Logger log = LoggerFactory.getLogger(ButlerCommissionService.class);
+
+    @Resource
+    private ButlerServiceRecordMapper serviceRecordMapper;
+
+    @Resource
+    private ButlerCommissionSettlementMapper settlementMapper;
+
+    @Resource
+    private ButlerProfileMapper butlerProfileMapper;
+
+    @Resource
+    private SysConfigMapper sysConfigMapper;
+
+    // 默认配置(基点为10000=100%)
+    private static final int DEFAULT_PLATFORM_RATE = 2000;       // 平台服务费率 20%
+    private static final int DEFAULT_ANNUAL_L1_RATE = 1000;      // L1年费管家分润率 10%
+    private static final int DEFAULT_ANNUAL_L2_RATE = 5000;      // L2年费管家分润率 50%
+    private static final int DEFAULT_PER_SESSION_PLATFORM_RATE = 2000; // 按次平台费率 20%
+
+    /**
+     * 创建按次服务记录
+     * @param butlerId 管家ID
+     * @param memberUserId 会员ID
+     * @param amount 服务金额(P点)
+     * @param remark 备注
+     * @return 创建的服务记录
+     */
+    @Transactional
+    public ButlerServiceRecord createServiceRecord(Long butlerId, Long memberUserId, Long amount, String remark) {
+        int platformRate = getSysConfigInt("butler_per_session_platform_rate", DEFAULT_PER_SESSION_PLATFORM_RATE);
+        long platformFee = amount * platformRate / 10000;
+        long butlerAmount = amount - platformFee;
+
+        ButlerServiceRecord record = new ButlerServiceRecord();
+        record.setButlerId(butlerId);
+        record.setMemberUserId(memberUserId);
+        record.setServiceType("per_session");
+        record.setAmountP(amount);
+        record.setPlatformFeeP(platformFee);
+        record.setButlerAmountP(butlerAmount);
+        record.setYearMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
+        record.setRemark(remark);
+        record.setCreatedAt(new Date());
+        serviceRecordMapper.insert(record);
+
+        log.info("Created per-session service record: butlerId={}, amount={}, platformFee={}, butlerAmount={}",
+                butlerId, amount, platformFee, butlerAmount);
+        return record;
+    }
+
+    /**
+     * 计算指定管家某月的结算
+     * @param butlerId 管家ID
+     * @param yearMonth YYYY-MM格式
+     * @return 结算单ID
+     */
+    @Transactional
+    public Long calculateButlerSettlement(Long butlerId, String yearMonth) {
+        // 查询管家当月所有服务记录
+        List<ButlerServiceRecord> records = serviceRecordMapper.selectList(
+                new LambdaQueryWrapper<ButlerServiceRecord>()
+                        .eq(ButlerServiceRecord::getButlerId, butlerId)
+                        .eq(ButlerServiceRecord::getYearMonth, yearMonth)
+        );
+
+        if (records.isEmpty()) {
+            log.info("No service records for butlerId={} in {}", butlerId, yearMonth);
+            return null;
+        }
+
+        // 统计各项
+        long annualBaseAmount = 0;
+        long annualButlerAmount = 0;
+        long annualPlatformAmount = 0;
+        long perSessionTotal = 0;
+        long perSessionPlatformTotal = 0;
+        long perSessionButlerTotal = 0;
+        long totalButlerAmount = 0;
+        int memberCount = 0;
+
+        for (ButlerServiceRecord r : records) {
+            if ("annual".equals(r.getServiceType())) {
+                annualBaseAmount += r.getAmountP() != null ? r.getAmountP() : 0;
+                annualButlerAmount += r.getButlerAmountP() != null ? r.getButlerAmountP() : 0;
+                annualPlatformAmount += r.getPlatformFeeP() != null ? r.getPlatformFeeP() : 0;
+            } else if ("per_session".equals(r.getServiceType())) {
+                perSessionTotal += r.getAmountP() != null ? r.getAmountP() : 0;
+                perSessionPlatformTotal += r.getPlatformFeeP() != null ? r.getPlatformFeeP() : 0;
+                perSessionButlerTotal += r.getButlerAmountP() != null ? r.getButlerAmountP() : 0;
+            }
+        }
+        totalButlerAmount = annualButlerAmount + perSessionButlerTotal;
+
+        ButlerProfile profile = butlerProfileMapper.selectOne(
+                new LambdaQueryWrapper<ButlerProfile>().eq(ButlerProfile::getUserId, butlerId));
+        if (profile != null) {
+            memberCount = profile.getMemberCount() != null ? profile.getMemberCount() : 0;
+        }
+
+        // 创建或更新结算单
+        ButlerCommissionSettlement existing = settlementMapper.selectOne(
+                new LambdaQueryWrapper<ButlerCommissionSettlement>()
+                        .eq(ButlerCommissionSettlement::getButlerId, butlerId)
+                        .eq(ButlerCommissionSettlement::getYearMonth, yearMonth)
+        );
+
+        if (existing != null) {
+            existing.setMemberCount(memberCount);
+            existing.setAnnualBaseAmountP(annualBaseAmount);
+            existing.setAnnualButlerAmountP(annualButlerAmount);
+            existing.setAnnualPlatformAmountP(annualPlatformAmount);
+            existing.setPerSessionTotalP(perSessionTotal);
+            existing.setPerSessionPlatformTotalP(perSessionPlatformTotal);
+            existing.setPerSessionButlerTotalP(perSessionButlerTotal);
+            existing.setTotalButlerAmountP(totalButlerAmount);
+            settlementMapper.updateById(existing);
+            log.info("Updated settlement id={} for butlerId={}, month={}, totalButlerAmount={}",
+                    existing.getId(), butlerId, yearMonth, totalButlerAmount);
+            return existing.getId();
+        } else {
+            ButlerCommissionSettlement settlement = new ButlerCommissionSettlement();
+            settlement.setButlerId(butlerId);
+            settlement.setYearMonth(yearMonth);
+            settlement.setMemberCount(memberCount);
+            settlement.setAnnualBaseAmountP(annualBaseAmount);
+            settlement.setAnnualButlerAmountP(annualButlerAmount);
+            settlement.setAnnualPlatformAmountP(annualPlatformAmount);
+            settlement.setPerSessionTotalP(perSessionTotal);
+            settlement.setPerSessionPlatformTotalP(perSessionPlatformTotal);
+            settlement.setPerSessionButlerTotalP(perSessionButlerTotal);
+            settlement.setTotalButlerAmountP(totalButlerAmount);
+            settlement.setStatus("pending");
+            settlement.setCreatedAt(new Date());
+            settlementMapper.insert(settlement);
+            log.info("Created settlement id={} for butlerId={}, month={}, totalButlerAmount={}",
+                    settlement.getId(), butlerId, yearMonth, totalButlerAmount);
+            return settlement.getId();
+        }
+    }
+
+    /**
+     * 批量计算所有管家的月度结算
+     * @param yearMonth YYYY-MM格式,null表示上月
+     * @return 处理的管家数
+     */
+    @Transactional
+    public int batchCalculateMonthlySettlement(String yearMonth) {
+        if (yearMonth == null) {
+            LocalDate lastMonth = LocalDate.now().minusMonths(1);
+            yearMonth = lastMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"));
+        }
+
+        // 查询所有审核通过的管家
+        List<ButlerProfile> butlers = butlerProfileMapper.selectList(
+                new LambdaQueryWrapper<ButlerProfile>()
+                        .eq(ButlerProfile::getStatus, "approved")
+        );
+
+        int count = 0;
+        for (ButlerProfile butler : butlers) {
+            Long settlementId = calculateButlerSettlement(butler.getUserId(), yearMonth);
+            if (settlementId != null) {
+                count++;
+            }
+        }
+
+        log.info("Batch settlement for {}: processed {} butlers", yearMonth, count);
+        return count;
+    }
+
+    /**
+     * 标记结算单为已发放
+     */
+    @Transactional
+    public void markSettlementPaid(Long settlementId, Long settledBy) {
+        ButlerCommissionSettlement settlement = settlementMapper.selectById(settlementId);
+        if (settlement == null) {
+            log.warn("Settlement not found: id={}", settlementId);
+            return;
+        }
+        settlement.setStatus("paid");
+        settlement.setSettledAt(new Date());
+        settlement.setSettledBy(settledBy);
+        settlementMapper.updateById(settlement);
+        log.info("Marked settlement id={} as paid", settlementId);
+    }
+
+    private int getSysConfigInt(String key, int defaultValue) {
+        SysConfig config = sysConfigMapper.selectOne(
+                new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
+        if (config != null && config.getConfigValue() != null) {
+            try {
+                return Integer.parseInt(config.getConfigValue());
+            } catch (NumberFormatException e) {
+                log.warn("Invalid sys_config value for {}: {}", key, config.getConfigValue());
+            }
+        }
+        return defaultValue;
+    }
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/ButlerCommissionScheduledTask.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.service.ButlerCommissionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 管家佣金结算定时任务
+ * 每月1日凌晨3:00自动结算上月管家佣金
+ */
+@Slf4j
+@Component
+public class ButlerCommissionScheduledTask {
+
+    @Resource
+    private ButlerCommissionService butlerCommissionService;
+
+    /**
+     * 每月1日 3:00 AM 执行
+     */
+    @Scheduled(cron = "0 0 3 1 * ?")
+    public void settleMonthlyCommissions() {
+        log.info("开始执行管家月度佣金结算定时任务");
+        try {
+            int count = butlerCommissionService.batchCalculateMonthlySettlement(null);
+            log.info("管家月度佣金结算完成,共处理{}个管家", count);
+        } catch (Exception e) {
+            log.error("管家月度佣金结算定时任务执行失败", e);
+        }
+    }
+}

+ 46 - 0
cfc-web/src/api/butler.js

@@ -0,0 +1,46 @@
+import request from '@/utils/request'
+
+// 管家列表
+export function getButlerList(params) {
+  return request({
+    url: '/api/admin/butler/list',
+    method: 'post',
+    data: params
+  })
+}
+
+// 审核管家
+export function reviewButler(data) {
+  return request({
+    url: '/api/admin/butler/review',
+    method: 'post',
+    data: data
+  })
+}
+
+// 管家服务记录
+export function getButlerServiceRecords(params) {
+  return request({
+    url: '/api/admin/butler/service-records',
+    method: 'post',
+    data: params
+  })
+}
+
+// 管家佣金结算
+export function getButlerSettlements(params) {
+  return request({
+    url: '/api/admin/butler/settlements',
+    method: 'post',
+    data: params
+  })
+}
+
+// 分配成员到管家
+export function assignMemberToButler(data) {
+  return request({
+    url: '/api/admin/butler/assign-member',
+    method: 'post',
+    data: data
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -216,6 +216,12 @@ const routes = [
         component: () => import('@/views/admin/VendorReview.vue'),
         meta: { title: '服务商审核', perm: 'audit:vendor' }
       },
+      {
+        path: 'butler-review',
+        name: 'ButlerReview',
+        component: () => import('@/views/admin/ButlerReview.vue'),
+        meta: { title: '管家审核', perm: 'audit:vendor' }
+      },
       {
         path: 'product-manage',
         name: 'ProductManage',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -184,6 +184,7 @@ export default {
             { path: '/package-audit', label: '套餐审核', icon: 'el-icon-document-checked', perm: 'audit:package' },
             { path: '/guide-audit', label: '规划师审核', icon: 'el-icon-document-checked', perm: 'audit:guide' },
             { path: '/vendor-review', label: '服务商审核', icon: 'el-icon-document-checked', perm: 'audit:vendor' },
+            { path: '/butler-review', label: '管家审核', icon: 'el-icon-document-checked', perm: 'audit:vendor' },
             { path: '/withdraw-audit', label: '提现审核', icon: 'el-icon-document-checked', perm: 'audit:withdraw' },
           ]},
 

+ 264 - 0
cfc-web/src/views/admin/ButlerReview.vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="butler-review admin-page">
+    <el-tabs v-model="activeTab" @tab-click="onTabChange">
+      <el-tab-pane label="管家审核" name="review">
+        <div class="header admin-page-header">
+          <h2 class="admin-page-title">管家审核</h2>
+          <div class="flex items-center gap-sm">
+            <el-select v-model="statusFilter" placeholder="审核状态" @change="loadList">
+              <el-option label="全部状态" value="" />
+              <el-option label="待审核" value="pending" />
+              <el-option label="已通过" value="approved" />
+              <el-option label="已拒绝" value="rejected" />
+            </el-select>
+          </div>
+        </div>
+
+        <div class="table-scroll-wrap">
+          <el-table :data="list" v-loading="loading" border stripe>
+            <el-table-column prop="id" label="档案ID" width="80" />
+            <el-table-column prop="userId" label="用户ID" width="80" />
+            <el-table-column label="管家等级" width="100">
+              <template slot-scope="{ row }">{{ row.tier || '-' }}</template>
+            </el-table-column>
+            <el-table-column label="审核状态" width="100">
+              <template slot-scope="{ row }">
+                <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="description" label="申请说明" min-width="200" show-overflow-tooltip />
+            <el-table-column prop="memberCount" label="已服务人数" width="100" />
+            <el-table-column prop="maxMembers" label="上限人数" width="80" />
+            <el-table-column prop="rejectReason" label="拒绝原因" width="150" show-overflow-tooltip />
+            <el-table-column label="操作" width="160" fixed="right">
+              <template slot-scope="{ row }">
+                <template v-if="row.status === 'pending'">
+                  <el-button size="mini" type="success" @click="handleReview(row, 'approve')">通过</el-button>
+                  <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
+                    <el-button size="mini">
+                      更多<i class="el-icon-arrow-down el-icon--right"></i>
+                    </el-button>
+                    <el-dropdown-menu slot="dropdown">
+                      <el-dropdown-item command="reject" icon="el-icon-close">拒绝</el-dropdown-item>
+                    </el-dropdown-menu>
+                  </el-dropdown>
+                </template>
+                <span v-else class="no-op">—</span>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+
+        <el-pagination
+          @size-change="onPageChange"
+          @current-change="onPageChange"
+          :current-page="page"
+          :page-size="size"
+          :total="total"
+          layout="total, prev, pager, next"
+          class="pagination-wrap"
+        />
+
+        <el-dialog title="填写拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
+          <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
+          <span slot="footer">
+            <el-button @click="rejectDialogVisible = false">取消</el-button>
+            <el-button type="danger" @click="confirmReject">确认拒绝</el-button>
+          </span>
+        </el-dialog>
+      </el-tab-pane>
+
+      <el-tab-pane label="服务记录" name="service-records">
+        <div class="header admin-page-header">
+          <h2 class="admin-page-title">管家服务记录</h2>
+        </div>
+        <div class="table-scroll-wrap">
+          <el-table :data="serviceRecords" v-loading="srLoading" border stripe>
+            <el-table-column prop="id" label="记录ID" width="80" />
+            <el-table-column prop="butlerId" label="管家ID" width="80" />
+            <el-table-column prop="memberUserId" label="会员ID" width="80" />
+            <el-table-column label="服务类型" width="120">
+              <template slot-scope="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
+            </el-table-column>
+            <el-table-column prop="amountP" label="金额(P点)" width="100" />
+            <el-table-column prop="platformFeeP" label="平台留成" width="100" />
+            <el-table-column prop="butlerAmountP" label="管家所得" width="100" />
+            <el-table-column prop="yearMonth" label="结算年月" width="100" />
+            <el-table-column prop="createdAt" label="创建时间" width="160" />
+          </el-table>
+        </div>
+        <el-pagination
+          @size-change="srPage = $event; loadServiceRecords()"
+          @current-change="srPage = $event; loadServiceRecords()"
+          :current-page="srPage"
+          :page-size="srSize"
+          :total="srTotal"
+          layout="total, prev, pager, next"
+          class="pagination-wrap"
+        />
+      </el-tab-pane>
+
+      <el-tab-pane label="佣金结算" name="settlements">
+        <div class="header admin-page-header">
+          <h2 class="admin-page-title">管家佣金结算</h2>
+        </div>
+        <div class="table-scroll-wrap">
+          <el-table :data="settlements" v-loading="stLoading" border stripe>
+            <el-table-column prop="id" label="结算单ID" width="80" />
+            <el-table-column prop="butlerId" label="管家ID" width="80" />
+            <el-table-column prop="yearMonth" label="结算年月" width="100" />
+            <el-table-column prop="memberCount" label="服务家庭数" width="100" />
+            <el-table-column prop="annualBaseAmountP" label="年费基准" width="100" />
+            <el-table-column prop="totalButlerAmountP" label="管家总所得" width="120" />
+            <el-table-column label="结算状态" width="100">
+              <template slot-scope="{ row }">
+                <el-tag :type="row.status === 'paid' ? 'success' : 'warning'">{{ row.status === 'paid' ? '已结算' : '待结算' }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="settledAt" label="结算时间" width="160" />
+          </el-table>
+        </div>
+        <el-pagination
+          @size-change="stPage = $event; loadSettlements()"
+          @current-change="stPage = $event; loadSettlements()"
+          :current-page="stPage"
+          :page-size="stSize"
+          :total="stTotal"
+          layout="total, prev, pager, next"
+          class="pagination-wrap"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import { getButlerList, reviewButler, getButlerServiceRecords, getButlerSettlements } from '@/api/butler'
+
+export default {
+  data() {
+    return {
+      activeTab: 'review',
+      // 审核列表
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      statusFilter: 'pending',
+      rejectDialogVisible: false,
+      rejectTarget: null,
+      rejectReason: '',
+      // 服务记录
+      serviceRecords: [],
+      srLoading: false,
+      srPage: 1,
+      srSize: 20,
+      srTotal: 0,
+      // 结算
+      settlements: [],
+      stLoading: false,
+      stPage: 1,
+      stSize: 20,
+      stTotal: 0
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    statusType(status) {
+      const map = { pending: 'warning', approved: 'success', rejected: 'danger' }
+      return map[status] || 'info'
+    },
+    statusLabel(status) {
+      const map = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
+      return map[status] || status
+    },
+    serviceTypeLabel(type) {
+      const map = { annual: '年费服务', per_session: '按次服务' }
+      return map[type] || type
+    },
+    onTabChange() {
+      this.page = 1
+      if (this.activeTab === 'review') this.loadList()
+      else if (this.activeTab === 'service-records') this.loadServiceRecords()
+      else if (this.activeTab === 'settlements') this.loadSettlements()
+    },
+    async loadList() {
+      this.loading = true
+      try {
+        const params = { page: this.page, size: this.size, status: this.statusFilter }
+        const res = await getButlerList(params)
+        this.list = res.data.list || []
+        this.total = res.data.total || 0
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadServiceRecords() {
+      this.srLoading = true
+      try {
+        const res = await getButlerServiceRecords({ page: this.srPage, size: this.srSize })
+        this.serviceRecords = res.data.list || []
+        this.srTotal = res.data.total || 0
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.srLoading = false
+      }
+    },
+    async loadSettlements() {
+      this.stLoading = true
+      try {
+        const res = await getButlerSettlements({ page: this.stPage, size: this.stSize })
+        this.settlements = res.data.list || []
+        this.stTotal = res.data.total || 0
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.stLoading = false
+      }
+    },
+    onPageChange(val) {
+      this.page = val
+      this.loadList()
+    },
+    async handleReview(row, action) {
+      try {
+        await reviewButler({ userId: row.userId, action: action, reason: this.rejectReason })
+        this.$message.success('审核完成')
+        this.loadList()
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
+    showRejectDialog(row) {
+      this.rejectTarget = row
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    handleActionCmd(row, cmd) {
+      switch (cmd) {
+        case 'reject': this.showRejectDialog(row); break
+      }
+    },
+    async confirmReject() {
+      if (!this.rejectReason) {
+        this.$message.warning('请填写拒绝原因')
+        return
+      }
+      await this.handleReview(this.rejectTarget, 'reject')
+      this.rejectDialogVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.no-op {
+  color: #ccc;
+}
+</style>