shinnosuke
2026-08-04 ff4babc2be1809f6ce03eb48c23f55920f447125
feat: align withdrawal management with admin prototype
6个文件已修改
369 ■■■■■ 已修改文件
talent-card-admin/src/api/admin.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/router/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts
@@ -81,7 +81,17 @@
export function updateAdminStaff(id: number | string, payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password?: string }) { return http.put<AdminStaff, AdminStaff>(`/api/admin/management/staff/${id}`, payload) }
export function resetAdminStaffPassword(id: number | string, password: string) { return http.patch<any, any>(`/api/admin/management/staff/${id}/password`, { password }) }
export function deleteAdminStaff(id: number | string) { return http.delete<any, any>(`/api/admin/management/staff/${id}`) }
export function getMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals') { return http.get<PageResult<any>, PageResult<any>>(`/api/admin/management/merchant-settlement/${kind}`) }
export interface MerchantSettlementQuery {
  page?: number
  size?: number
  merchantName?: string
  contactMobile?: string
  merchantType?: string
  status?: string
  startDate?: string
  endDate?: string
}
export function getMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals', params: MerchantSettlementQuery = {}) { return http.get<PageResult<any>, PageResult<any>>(`/api/admin/management/merchant-settlement/${kind}`, { params }) }
export function reviewMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals', id: number | string, status: string, reason = '', providerValue = '') { const body: Record<string, string> = { status, reason }; if (kind === 'onboardings') body.huifuId = providerValue; if (kind === 'bank-cards') body.tokenNo = providerValue; return http.patch<any, any>(`/api/admin/management/merchant-settlement/${kind}/${id}`, body) }
export function getAdminNews(params: NewsArticleQuery = {}) {
talent-card-admin/src/layouts/AdminLayout.vue
@@ -82,7 +82,7 @@
]
const financeMenus: MenuItem[] = [
  { path: '/merchant-settlement', label: '商户结算审核' },
  { path: '/merchant-settlement', label: '提现管理' },
]
const orderMenus: MenuItem[] = [
talent-card-admin/src/router/index.ts
@@ -77,7 +77,7 @@
          path: 'merchant-settlement',
          name: 'merchant-settlement',
          component: () => import('../views/MerchantSettlementView.vue'),
          meta: { title: '商户结算审核' },
          meta: { title: '提现管理' },
        },
        {
          path: 'products',
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -1,26 +1,299 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { getMerchantSettlement, reviewMerchantSettlement } from '../api/admin'
type Kind = 'onboardings' | 'bank-cards' | 'withdrawals'
const kind = ref<Kind>('onboardings'), loading = ref(false), rows = ref<any[]>([])
const labels: Record<string,string> = { pending: '待审核', approved: '已通过', rejected: '已驳回', processing: '取现处理中', paid: '已到账', failed: '失败' }
const filters = reactive({ merchantName: '', applicationNo: '', status: '' })
const filteredRows = computed(() => rows.value.filter((row) => {
  const merchantMatched = !filters.merchantName || String(row.merchantName || '').includes(filters.merchantName.trim())
  const applicationMatched = !filters.applicationNo || String(row.applicationNo || '').includes(filters.applicationNo.trim())
  return merchantMatched && applicationMatched && (!filters.status || row.status === filters.status)
}))
const load = async () => { loading.value = true; try { rows.value = (await getMerchantSettlement(kind.value)).records || [] } finally { loading.value = false } }
const resetFilters = () => { filters.merchantName = ''; filters.applicationNo = ''; filters.status = '' }
const review = async (row: any, status: string) => {
  let reason = '', providerValue = ''
  if (status === 'approved') { const isOnboarding = kind.value === 'onboardings'; const result = await ElMessageBox.prompt(isOnboarding ? '请输入汇付进件成功后返回的商户 huifu_id' : '请输入汇付绑卡成功后返回的取现卡 token_no', isOnboarding ? '配置汇付商户号' : '配置汇付取现卡序列号', { inputPattern: /^[A-Za-z0-9]{1,32}$/, inputErrorMessage: '请输入有效的汇付标识' }); providerValue = result.value }
  else if (status === 'rejected' || status === 'failed') { const result = await ElMessageBox.prompt('请输入处理原因', '确认处理'); reason = result.value || '' }
  else { await ElMessageBox.confirm('确认发起汇付取现?取现结果将以汇付同步返回或签名回调为准。', '发起取现', { type: 'warning' }) }
  await reviewMerchantSettlement(kind.value, row.id, status, reason, providerValue); ElMessage.success(status === 'paid' ? '已提交汇付取现' : '处理成功'); load()
const kind = ref<Kind>('withdrawals')
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const reviewVisible = ref(false)
const reviewing = ref<Record<string, any> | null>(null)
const filters = reactive({
  merchantName: '',
  contactMobile: '',
  merchantType: '',
  status: '',
  dateRange: [] as string[],
})
const tabOptions: Array<{ value: Kind; label: string }> = [
  { value: 'withdrawals', label: '提现管理' },
  { value: 'onboardings', label: '商户进件' },
  { value: 'bank-cards', label: '银行卡申请' },
]
const merchantTypes = ['餐饮服务', '住宿服务', '购房服务', '购车服务', '体检服务', '金融服务', '行政审批', '景区服务']
const statusOptions = computed(() => kind.value === 'withdrawals'
  ? [
      { value: 'pending', label: '待审核' },
      { value: 'processing', label: '处理中' },
      { value: 'paid', label: '已到账' },
      { value: 'failed', label: '提现失败' },
    ]
  : [
      { value: 'pending', label: '待审核' },
      { value: 'approved', label: '已通过' },
      { value: 'rejected', label: '已驳回' },
    ])
function statusLabel(value: string) {
  return ({ pending: '待审核', approved: '已通过', rejected: '已驳回', processing: '处理中', paid: '已到账', failed: '提现失败' } as Record<string, string>)[value] || value || '-'
}
function statusType(value: string) {
  return ({ pending: 'warning', approved: 'success', paid: 'success', rejected: 'danger', failed: 'danger', processing: 'primary' } as Record<string, 'warning' | 'success' | 'danger' | 'primary'>)[value] || 'primary'
}
function formatAmount(value: unknown) {
  if (value === null || value === undefined || value === '') return '-'
  return `¥${(Number(value) / 100).toFixed(2)}`
}
function formatDate(value: unknown) {
  if (!value) return '-'
  return String(value).replace('T', ' ').replace(/\.\d+$/, '')
}
async function load() {
  loading.value = true
  try {
    const result = await getMerchantSettlement(kind.value, {
      page: page.value,
      size: pageSize.value,
      merchantName: filters.merchantName.trim() || undefined,
      contactMobile: filters.contactMobile.trim() || undefined,
      merchantType: filters.merchantType || undefined,
      status: filters.status || undefined,
      startDate: filters.dateRange[0],
      endDate: filters.dateRange[1],
    })
    rows.value = result.records || []
    total.value = Number(result.total || 0)
  } finally {
    loading.value = false
  }
}
function search() {
  page.value = 1
  load()
}
function reset() {
  filters.merchantName = ''
  filters.contactMobile = ''
  filters.merchantType = ''
  filters.status = ''
  filters.dateRange = []
  page.value = 1
  load()
}
function changeKind(value: string | number | boolean | undefined) {
  kind.value = value as Kind
  page.value = 1
  filters.status = ''
  load()
}
function openReview(row: Record<string, any>) {
  reviewing.value = row
  reviewVisible.value = true
}
async function review(status: 'paid' | 'failed' | 'approved' | 'rejected') {
  const row = reviewing.value
  if (!row) return
  let reason = ''
  let providerValue = ''
  try {
    if (status === 'paid') {
      await ElMessageBox.confirm('确认发起该笔提现的汇付代付?代付结果以汇付同步响应或签名回调为准。', '确认审核通过', { type: 'warning' })
    } else if (status === 'approved') {
      const onboarding = kind.value === 'onboardings'
      const result = await ElMessageBox.prompt(
        onboarding ? '请输入汇付进件成功后返回的商户 huifu_id' : '请输入汇付绑卡成功后返回的提现卡 token_no',
        onboarding ? '配置汇付商户号' : '配置汇付提现卡序列号',
        { inputPattern: /^[A-Za-z0-9]{1,32}$/, inputErrorMessage: '请输入有效的汇付标识' },
      )
      providerValue = result.value
    } else {
      const result = await ElMessageBox.prompt('请输入处理原因', '驳回申请', {
        inputValidator: (value) => value.trim() ? true : '请填写驳回原因',
      })
      reason = result.value.trim()
    }
    await reviewMerchantSettlement(kind.value, row.id, status, reason, providerValue)
    ElMessage.success(status === 'paid' ? '已提交汇付代付' : '审核处理成功')
    reviewVisible.value = false
    load()
  } catch {
    // Element Plus confirmation and prompt cancellations do not need a user-facing error.
  }
}
function handlePageChange(value: number) {
  page.value = value
  load()
}
onMounted(load)
</script>
<template><div class="page-stack"><el-card><div class="toolbar"><el-radio-group v-model="kind" @change="load"><el-radio-button label="onboardings">商户进件</el-radio-button><el-radio-button label="bank-cards">银行卡审核</el-radio-button><el-radio-button label="withdrawals">提现审核</el-radio-button></el-radio-group><el-button @click="load">刷新</el-button></div><el-form class="settlement-filters" inline><el-form-item label="商户"><el-input v-model="filters.merchantName" clearable placeholder="请输入商户名称" /></el-form-item><el-form-item label="申请单号"><el-input v-model="filters.applicationNo" clearable placeholder="请输入申请单号" /></el-form-item><el-form-item label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="待审核" value="pending" /><el-option label="已通过" value="approved" /><el-option label="已驳回" value="rejected" /><el-option label="处理中" value="processing" /><el-option label="已到账" value="paid" /><el-option label="失败" value="failed" /></el-select></el-form-item><el-form-item><el-button @click="resetFilters">重置</el-button></el-form-item></el-form><el-table v-loading="loading" :data="filteredRows" border><el-table-column prop="merchantName" label="商户"/><el-table-column prop="storeName" label="门店"/><el-table-column prop="applicationNo" label="申请单号"/><el-table-column prop="bankName" label="银行"/><el-table-column label="金额"><template #default="{row}">{{row.amountCents == null ? '-' : '¥' + (Number(row.amountCents)/100).toFixed(2)}}</template></el-table-column><el-table-column label="状态"><template #default="{row}">{{labels[row.status] || row.status}}</template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><template v-if="row.status==='pending'"><el-button size="small" type="primary" @click="review(row,kind==='withdrawals'?'paid':'approved')">{{kind==='withdrawals'?'发起取现':'通过'}}</el-button><el-button size="small" type="danger" @click="review(row,kind==='withdrawals'?'failed':'rejected')">驳回</el-button></template></template></el-table-column></el-table></el-card></div></template>
<style scoped>.toolbar{display:flex;justify-content:space-between;margin-bottom:18px}.settlement-filters{padding:14px 0 4px}</style>
<template>
  <div class="withdrawal-page">
    <div class="settlement-tabs">
      <el-radio-group v-model="kind" @change="changeKind">
        <el-radio-button v-for="tab in tabOptions" :key="tab.value" :label="tab.value">
          {{ tab.label }}
        </el-radio-button>
      </el-radio-group>
    </div>
    <section class="query-panel">
      <div class="panel-title">查询表格</div>
      <el-form class="query-form" label-width="70px" @submit.prevent="search">
        <el-form-item label="商家">
          <el-input v-model="filters.merchantName" clearable placeholder="请输入商家名称" />
        </el-form-item>
        <el-form-item label="联系电话">
          <el-input v-model="filters.contactMobile" clearable placeholder="请输入商家联系电话" />
        </el-form-item>
        <el-form-item label="申请时间">
          <el-date-picker
            v-model="filters.dateRange"
            type="daterange"
            value-format="YYYY-MM-DD"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            unlink-panels
          />
        </el-form-item>
        <el-form-item label="商家类型">
          <el-select v-model="filters.merchantType" clearable placeholder="全部">
            <el-option v-for="item in merchantTypes" :key="item" :label="item" :value="item" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="filters.status" clearable placeholder="全部">
            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-form-item>
        <el-form-item class="query-actions">
          <el-button type="primary" :icon="Search" @click="search">查询</el-button>
          <el-button :icon="Refresh" @click="reset">重置</el-button>
        </el-form-item>
      </el-form>
    </section>
    <section class="table-panel">
      <el-table v-loading="loading" :data="rows" border class="settlement-table" empty-text="暂无数据">
        <template v-if="kind === 'withdrawals'">
          <el-table-column prop="withdrawalNo" label="提现Id" min-width="156" />
          <el-table-column prop="merchantName" label="商家名称" min-width="130" show-overflow-tooltip />
          <el-table-column prop="contactMobile" label="联系方式" min-width="118" />
          <el-table-column prop="merchantType" label="商家类型" min-width="110" />
          <el-table-column label="可提现金额" min-width="116" align="right">
            <template #default="{ row }">{{ formatAmount(row.availableAmountCents) }}</template>
          </el-table-column>
          <el-table-column label="提现金额" min-width="104" align="right">
            <template #default="{ row }">{{ formatAmount(row.amountCents) }}</template>
          </el-table-column>
          <el-table-column prop="bankName" label="提现银行" min-width="130" show-overflow-tooltip />
          <el-table-column prop="cardMasked" label="提现卡号" min-width="150" />
        </template>
        <template v-else-if="kind === 'onboardings'">
          <el-table-column prop="applicationNo" label="进件申请Id" min-width="156" />
          <el-table-column prop="merchantName" label="商家名称" min-width="150" show-overflow-tooltip />
          <el-table-column prop="contactMobile" label="联系方式" min-width="118" />
          <el-table-column prop="merchantType" label="商家类型" min-width="110" />
          <el-table-column prop="subjectName" label="进件主体" min-width="150" show-overflow-tooltip />
          <el-table-column prop="subjectType" label="主体类型" min-width="105" />
        </template>
        <template v-else>
          <el-table-column prop="id" label="申请Id" min-width="100" />
          <el-table-column prop="merchantName" label="商家名称" min-width="150" show-overflow-tooltip />
          <el-table-column prop="contactMobile" label="联系方式" min-width="118" />
          <el-table-column prop="merchantType" label="商家类型" min-width="110" />
          <el-table-column prop="bankName" label="开户银行" min-width="145" show-overflow-tooltip />
          <el-table-column prop="branchName" label="开户支行" min-width="180" show-overflow-tooltip />
          <el-table-column prop="cardMasked" label="银行卡号" min-width="150" />
        </template>
        <el-table-column label="申请时间" min-width="165">
          <template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
        </el-table-column>
        <el-table-column label="状态" min-width="100" align="center">
          <template #default="{ row }">
            <el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="90" fixed="right" align="center">
          <template #default="{ row }">
            <el-button v-if="row.status === 'pending'" link type="primary" @click="openReview(row)">审核</el-button>
            <span v-else class="muted">-</span>
          </template>
        </el-table-column>
      </el-table>
      <div class="pagination-bar">
        <span>共 {{ total }} 条</span>
        <el-pagination
          v-model:current-page="page"
          v-model:page-size="pageSize"
          background
          layout="prev, pager, next"
          :total="total"
          @current-change="handlePageChange"
        />
      </div>
    </section>
    <el-dialog v-model="reviewVisible" title="提现审核" width="400px" :close-on-click-modal="false">
      <template v-if="reviewing">
        <div class="review-summary">
          <div><span>商家名称</span><strong>{{ reviewing.merchantName || '-' }}</strong></div>
          <div><span>提现金额</span><strong>{{ formatAmount(reviewing.amountCents) }}</strong></div>
          <div><span>提现银行</span><strong>{{ reviewing.bankName || '-' }} {{ reviewing.cardMasked || '' }}</strong></div>
        </div>
      </template>
      <template #footer>
        <el-button @click="reviewVisible = false">取消</el-button>
        <el-button type="danger" plain @click="review(kind === 'withdrawals' ? 'failed' : 'rejected')">驳回</el-button>
        <el-button type="primary" @click="review(kind === 'withdrawals' ? 'paid' : 'approved')">审核通过</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<style scoped>
.withdrawal-page { min-width: 1040px; }
.settlement-tabs { margin: 0 0 16px; }
.settlement-tabs :deep(.el-radio-button__inner) { min-width: 96px; }
.query-panel, .table-panel { background: #fff; border: 1px solid #edf0f5; }
.query-panel { padding: 18px 20px 6px; }
.panel-title { margin-bottom: 16px; color: #1d2129; font-size: 15px; font-weight: 600; }
.query-form { display: grid; grid-template-columns: repeat(3, minmax(250px, 1fr)); column-gap: 24px; }
.query-form :deep(.el-form-item) { margin-bottom: 16px; }
.query-form :deep(.el-form-item__content) { min-width: 0; }
.query-form :deep(.el-input), .query-form :deep(.el-select), .query-form :deep(.el-date-editor) { width: 100%; }
.query-actions { justify-content: flex-end; }
.query-actions :deep(.el-form-item__content) { justify-content: flex-end; }
.table-panel { margin-top: 16px; padding: 0 20px 12px; }
.settlement-table { width: 100%; }
.settlement-table :deep(th.el-table__cell) { color: #303133; font-weight: 600; background: #f7f8fa; }
.settlement-table :deep(.el-table__cell) { padding: 10px 0; }
.pagination-bar { display: flex; align-items: center; justify-content: flex-end; gap: 18px; padding-top: 14px; color: #606266; font-size: 13px; }
.muted { color: #909399; }
.review-summary { display: grid; gap: 14px; }
.review-summary div { display: flex; justify-content: space-between; gap: 16px; }
.review-summary span { color: #909399; }
.review-summary strong { color: #303133; font-weight: 500; text-align: right; }
@media (max-width: 1280px) { .query-form { grid-template-columns: repeat(2, minmax(280px, 1fr)); } }
</style>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -23,9 +23,9 @@
  @PutMapping("/staff/{id}") public ApiResponse<Map<String,Object>> updateStaff(@PathVariable Long id, @Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.updateStaff(id, request)); }
  @PatchMapping("/staff/{id}/password") public ApiResponse<Map<String,Object>> resetStaffPassword(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(service.resetStaffPassword(id, body.get("password"))); }
  @DeleteMapping("/staff/{id}") public ApiResponse<Map<String,Object>> deleteStaff(@PathVariable Long id) { return ApiResponse.success(service.deleteStaff(id)); }
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.onboardings(page, size)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.bankCards(page, size)); }
  @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.withdrawals(page, size)); }
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.onboardings(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @PatchMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> reviewOnboarding(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewOnboarding(id, body.get("status"), body.get("reason"), body.get("huifuId"))); }
  @PatchMapping("/merchant-settlement/bank-cards/{id}") public ApiResponse<Map<String,Object>> reviewBankCard(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewBankCard(id, body.get("status"), body.get("reason"), body.get("tokenNo"))); }
  @PatchMapping("/merchant-settlement/withdrawals/{id}") public ApiResponse<Map<String,Object>> reviewWithdrawal(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewWithdrawal(id, body.get("status"), body.get("reason"))); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -1,5 +1,6 @@
package com.talentcard.server.business.admin;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -20,11 +21,20 @@
    public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> onboardings(int page, int size) { return page("select a.*, m.merchant_name, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application", page, size); }
    public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        return page("select a.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "a", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> bankCards(int page, int size) { return page("select c.*, m.merchant_name, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card", page, size); }
    public PageResult<Map<String, Object>> bankCards(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        return page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> withdrawals(int page, int size) { return page("select w.*, c.bank_name, c.card_masked, m.merchant_name, s.store_name from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id", "select count(*) from merchant_withdrawal", page, size); }
    public PageResult<Map<String, Object>> withdrawals(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))";
        String select = "select w.*, c.bank_name, c.card_masked, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name, " + available + " as available_amount_cents from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        String count = "select count(*) from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        return page(select, count, "w", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
    }
    @Transactional public Map<String, Object> reviewOnboarding(Long id, String status, String reason, String huifuId) { return reviewOnboardingInternal(id, status, reason, huifuId); }
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); }
@@ -35,7 +45,27 @@
    private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; }
    private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; }
    private PageResult<Map<String, Object>> page(String query, String count, int page, int size) { int current = Math.max(1, page); int limit = Math.min(Math.max(1, size), 100); List<Map<String, Object>> values = rows(query + " order by id desc limit ? offset ?", limit, (current - 1) * limit); Long total = jdbc.queryForObject(count, Long.class); return new PageResult<>(values, current, limit, total == null ? 0 : total); }
    private PageResult<Map<String, Object>> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        int current = Math.max(1, page);
        int limit = Math.min(Math.max(1, size), 100);
        StringBuilder where = new StringBuilder(" where 1 = 1");
        List<Object> args = new ArrayList<>();
        addLike(where, args, "m.merchant_name", merchantName);
        addLike(where, args, "m.contact_mobile", contactMobile);
        addEqual(where, args, "m.merchant_type", merchantType);
        addEqual(where, args, tableAlias + ".status", status);
        addDate(where, args, tableAlias + ".created_at", startDate, true);
        addDate(where, args, tableAlias + ".created_at", endDate, false);
        List<Object> rowArgs = new ArrayList<>(args);
        rowArgs.add(limit);
        rowArgs.add((current - 1) * limit);
        List<Map<String, Object>> values = rows(query + where + " order by " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray());
        Long total = jdbc.queryForObject(count + where, Long.class, args.toArray());
        return new PageResult<>(values, current, limit, total == null ? 0 : total);
    }
    private void addLike(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" like ?"); args.add("%" + value.trim() + "%"); } }
    private void addEqual(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" = ?"); args.add(value.trim()); } }
    private void addDate(StringBuilder where, List<Object> args, String column, String value, boolean start) { if (value != null && value.matches("\\d{4}-\\d{2}-\\d{2}")) { where.append(" and ").append(column).append(start ? " >= ?" : " < date_add(?, interval 1 day)"); args.add(value); } }
    private Map<String, Object> update(String table, Long id, String status, String reason, String label) {
        String value = status == null ? "" : status.trim().toLowerCase();
        if (!List.of("approved", "rejected", "paid", "failed").contains(value)) throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态无效");