feat: 应收款催收闭环 — 催收视图+博海230万录入+回款登记联动

This commit is contained in:
Hermes CI Fix
2026-08-14 22:07:02 +08:00
parent a268c4e74d
commit 266277d121
6 changed files with 588 additions and 5 deletions
+4
View File
@@ -345,6 +345,10 @@ export const cashApi = {
updatePlan: (id: number, data: any) => api.put(`/cash/plans/${id}`, data),
deletePlan: (id: number) => api.delete(`/cash/plans/${id}`),
completePlan: (id: number) => api.post(`/cash/plans/${id}/complete`),
// 应收款催收
receivables: (params?: any) => api.get('/cash/receivables', { params }),
registerPayment: (id: number, data: any) => api.post(`/cash/receivables/${id}/payment`, data),
importBohaiAR: () => api.post('/cash/import/bohai-ar', {}),
// 到期提醒
upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
// 页面看板(日历+预测+提醒)
+3 -2
View File
@@ -9,8 +9,8 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/growth-quality', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/tax-compliance'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
}
@@ -35,6 +35,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+1
View File
@@ -43,6 +43,7 @@ const routes = [
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } },
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
]
+191
View File
@@ -0,0 +1,191 @@
<template>
<div class="receivables-page">
<div class="page-header">
<h2>💰 应收款催收</h2>
<div class="header-right">
<el-select v-model="owner" size="small" clearable placeholder="按负责人筛选" style="width:140px" @change="loadData">
<el-option v-for="o in owners" :key="o" :value="o" :label="o" />
</el-select>
<el-select v-model="status" size="small" clearable placeholder="按状态筛选" style="width:130px" @change="loadData">
<el-option value="pending" label="待收款" />
<el-option value="overdue" label="已逾期" />
<el-option value="completed" label="已回款" />
</el-select>
<el-button type="primary" size="small" @click="loadData">刷新</el-button>
</div>
</div>
<!-- 汇总卡片 -->
<el-row :gutter="16">
<el-col :span="6"><el-card shadow="never" class="stat-card red"><div class="stat-val">¥{{ (summary.total_balance || 0).toFixed(2) }}</div><div class="stat-label">应收总额</div></el-card></el-col>
<el-col :span="6"><el-card shadow="never" class="stat-card orange"><div class="stat-val">¥{{ (summary.overdue_amount || 0).toFixed(2) }}</div><div class="stat-label">逾期金额 ({{ summary.overdue_count || 0 }})</div></el-card></el-col>
<el-col :span="6"><el-card shadow="never" class="stat-card green"><div class="stat-val">¥{{ (summary.total_paid || 0).toFixed(2) }}</div><div class="stat-label">已回款</div></el-card></el-col>
<el-col :span="6"><el-card shadow="never" class="stat-card blue"><div class="stat-val">{{ summary.completed_count || 0 }}</div><div class="stat-label">已完成笔数</div></el-card></el-col>
</el-row>
<!-- 账龄分布 -->
<el-row style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never">
<template #header>账龄分布万元</template>
<div ref="agingRef" style="height:220px"></div>
</el-card>
</el-col>
</el-row>
<!-- 应收明细 -->
<el-row style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never">
<template #header>应收明细</template>
<el-table :data="items" size="small" v-loading="loading">
<el-table-column prop="name" label="客户/单位" min-width="140" />
<el-table-column prop="owner" label="负责人" width="90" />
<el-table-column prop="amount" label="金额(万)" width="90">
<template #default="{ row }"><span style="font-weight:600">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column prop="paid_amount" label="已回款(万)" width="100" />
<el-table-column prop="due_date" label="到期日" width="100" />
<el-table-column prop="aging_label" label="账龄" width="100">
<template #default="{ row }">
<el-tag :type="row.overdue ? 'danger' : (row.aging_label === '未到期' ? 'success' : 'warning')" size="small">
{{ row.aging_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : (row.overdue ? 'danger' : 'info')" size="small">
{{ row.status === 'completed' ? '已回款' : row.overdue ? '已逾期' : '待收款' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button v-if="row.status !== 'completed'" type="primary" size="small" @click="openPayment(row)">登记回款</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 回款登记弹窗 -->
<el-dialog v-model="paymentVisible" title="登记回款" width="420px" append-to-body>
<el-form label-width="90px">
<el-form-item label="客户">{{ currentRow?.name }}</el-form-item>
<el-form-item label="应收金额">¥{{ currentRow?.amount }}</el-form-item>
<el-form-item label="回款金额">
<el-input-number v-model="paymentAmount" :min="0" :max="currentRow?.amount || 0" :precision="2" style="width:180px" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="paymentNote" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="paymentVisible = false">取消</el-button>
<el-button type="primary" :loading="paying" @click="submitPayment">确认回款</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
import { ElMessage } from 'element-plus'
const items = ref<any[]>([])
const owners = ref<string[]>([])
const summary = ref<any>({})
const owner = ref('')
const status = ref('')
const loading = ref(false)
const paymentVisible = ref(false)
const currentRow = ref<any>(null)
const paymentAmount = ref(0)
const paymentNote = ref('')
const paying = ref(false)
const agingRef = ref<HTMLElement>()
async function loadData() {
loading.value = true
try {
const params: any = {}
if (owner.value) params.owner = owner.value
if (status.value) params.status = status.value
const r: any = await api.get('/cash/receivables', { params })
const d = r.data || r || {}
summary.value = d.summary || {}
owners.value = d.owners || []
const rawItems = d.data || d.items || d.receivables || []
items.value = rawItems
await nextTick()
renderAging()
} catch (e) {
ElMessage.error('加载应收数据失败')
} finally {
loading.value = false
}
}
function renderAging() {
if (!agingRef.value) return
const chart = echarts.init(agingRef.value)
const aging = summary.value.aging || {}
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: Object.keys(aging) },
yAxis: { type: 'value', name: '万元' },
series: [{
type: 'bar',
data: Object.values(aging),
itemStyle: { color: '#409EFF', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top' },
}],
})
}
function openPayment(row: any) {
currentRow.value = row
paymentAmount.value = row.amount || 0
paymentNote.value = ''
paymentVisible.value = true
}
async function submitPayment() {
if (!currentRow.value || paymentAmount.value <= 0) return
paying.value = true
try {
await api.post(`/cash/receivables/${currentRow.value.id}/payment`, {
amount: paymentAmount.value,
note: paymentNote.value,
})
ElMessage.success('回款登记成功')
paymentVisible.value = false
loadData()
} catch (e) {
ElMessage.error('回款登记失败')
} finally {
paying.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.receivables-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; }
.stat-card { text-align: center; }
.stat-val { font-size: 24px; font-weight: 700; }
.stat-label { font-size: 12px; color: #888; margin-top: 4px; }
.red .stat-val { color: #f56c6c; }
.orange .stat-val { color: #e6a23c; }
.green .stat-val { color: #67c23a; }
.blue .stat-val { color: #409EFF; }
</style>