feat(ar): 应收账龄看板 + 逾期预警 overdue 接入(阶段A)

T1 逾期预警(§6 财务 2026-10-20 23:23 口径):
- alert_rules 支持 rule_type=ar_overdue;阈值/责任人/排除名单全量走 params(代码零业务常量)
- 90 天占比红线 key 保留但默认 null 停用(不回落 0.25);黄线 15%-25% 保留
- 新增逐户红线「>30 天且 >5 万」+ 聚合红线「150+ 占比环比上升 = 结构恶化」
- assignee 派生:manual → 近 3 个月主业务员(zxt_masterbill emp_id → employee_dict.u_Name,
  缺表时降级同库摘要制单人姓名)→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);
  两路皆失败 ⇒ 该户不生成、进 skipped[],禁落空 assignee
- POST /run-ar-overdue(dry_run 默认 true)+ check-all 分支;只 INSERT 同一 message 幂等

T2 账龄看板:
- GET /api/cma/ar-aging/{meta,trend,overview,customers,customers/export};每条查询带 entity_id
- 三口径分列、caliber=all → 400;缺期 has_data=false 且金额 null(不补 0)
- ArAging.vue:口径后缀/截止期徽标/趋势断点/六档堆叠/逐户可排序导出

单测 21 项全通过(SQLite 夹具,不连生产库);vite build 通过。
阶段A 零生产写:未 push、未部署、未 --apply、未 dry_run=false。
This commit is contained in:
fullstack-bot
2026-10-21 08:42:24 +08:00
committed by Hermes CI Fix
parent 790be2d6b1
commit 71867c4c7a
11 changed files with 2552 additions and 4 deletions
+478
View File
@@ -0,0 +1,478 @@
<template>
<div class="ar-aging-page">
<!-- 页头:标题 + 口径后缀(强制显示)+ 数据截止期徽标 + 来源说明 -->
<header class="page-head">
<div class="head-main">
<h2 class="page-title">
应收账龄看板<span class="caliber-suffix">({{ caliberLabel }})</span>
</h2>
<div class="badge-row">
<el-tag size="small" effect="plain" :type="overview.has_data === false ? 'warning' : 'success'">
数据截止期 {{ displayPeriod || '无' }}
</el-tag>
<el-tag v-if="meta.loaded_at" size="small" effect="plain">入表 {{ meta.loaded_at }}</el-tag>
<el-tag size="small" effect="plain" type="primary">{{ entityLabel }}</el-tag>
<el-tag size="small" effect="plain" type="info">口径分列 · 禁跨口径合计</el-tag>
</div>
<p class="source-note">
来源:智享通「往来单位未收款账期统计」逐户导出;六档互斥(0-30/31-60/61-90/90-120/120-150/150+),
逐期六档和 = 期末应收合计;期初只展示,不作账龄基数。
</p>
</div>
<div class="head-controls">
<el-select v-model="caliber" size="small" style="width:190px" @change="onCaliberChange">
<el-option v-for="c in calibers" :key="c" :label="c" :value="c" />
</el-select>
<el-select
v-model="period" size="small" style="width:120px" :loading="loading"
placeholder="选择期间" @change="loadPeriodData"
>
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-button size="small" :loading="exporting" @click="doExport">
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
导出逐户 CSV
</el-button>
</div>
</header>
<!-- 表不可用/无权限:降级可读,不留白、不补 0 -->
<el-alert
v-if="metaLoaded && meta.available === false"
type="warning" :closable="false" show-icon
title="该期无数据(表未就绪/无权限)"
:description="meta.reason || 'hanke_ar_aging 不可用'"
/>
<template v-else>
<!-- 卡片区 -->
<el-row :gutter="16" v-loading="loading">
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="never" class="metric">
<div class="metric-label">应收总额(期末)</div>
<div class="metric-value">{{ money(overview.total_amt) }}</div>
<div class="metric-sub">
{{ overview.has_data ? `往来单位 ${overview.customers} 户` : '该期无数据' }}
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="never" class="metric">
<div class="metric-label">逾期金额(&gt;30 天)</div>
<div class="metric-value">{{ money(overview.overdue_amt) }}</div>
<div class="metric-sub">
环比
<span :class="momClass(overview.overdue_amt_mom_pct)">
{{ pctSigned(overview.overdue_amt_mom_pct) }}
</span>
<span class="dim">{{ overview.mom_note || (overview.prev_period ? `上期 ${overview.prev_period}` : '') }}</span>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="never" class="metric">
<div class="metric-label">90 天以上占比</div>
<div class="metric-value" :style="{ color: shareColor }">{{ pct(overview.share_90plus) }}</div>
<div class="metric-sub">
<span v-if="thresholds.share_90plus_red_gt != null" class="th-red">
红线 &gt;{{ pct(thresholds.share_90plus_red_gt) }}
</span>
<span v-if="thresholds.share_90plus_yellow_min != null" class="th-yellow">
黄线 {{ pct(thresholds.share_90plus_yellow_min) }}–{{ pct(thresholds.share_90plus_yellow_max) }}
</span>
<span v-if="thresholds.share_90plus_red_gt == null" class="dim">阈值未配置(阶段B 播种后显示)</span>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="never" class="metric">
<div class="metric-label">逾期户数(&gt;30 天)</div>
<div class="metric-value">{{ overview.overdue_customers ?? '该期无数据' }}</div>
<div class="metric-sub">
环比
<span :class="momClass(overview.overdue_customers_mom_pct)">
{{ pctSigned(overview.overdue_customers_mom_pct) }}
</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图区 -->
<el-row :gutter="16" class="chart-row">
<el-col :xs="24" :lg="14">
<el-card shadow="never">
<template #header>
<div class="card-head">
<span>逐期趋势 · 应收总额 / 逾期户数</span>
<span class="dim small">{{ trendRange }}</span>
</div>
</template>
<div ref="trendRef" class="chart-box"></div>
<p class="chart-note">缺数据期以灰带标注「该期无数据」,曲线断点、不补 0。</p>
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
<el-card shadow="never">
<template #header>
<div class="card-head">
<span>六档账龄堆叠</span>
<el-tag size="small" :type="bucketsBalanced ? 'success' : 'danger'" effect="plain">
六档和=合计 {{ balancedCount }}/{{ dataPointCount }}
</el-tag>
</div>
</template>
<div ref="stackRef" class="chart-box"></div>
<p class="chart-note">tooltip 内断言六档和与合计一致;不一致会标红。</p>
</el-card>
</el-col>
</el-row>
<!-- 逐户明细 -->
<el-card shadow="never" class="table-card">
<template #header>
<div class="card-head">
<span>逐户逾期清单 · {{ displayPeriod || '—' }}({{ caliberLabel }})</span>
<div class="table-tools">
<el-switch v-model="overdueOnly" size="small" active-text="仅逾期" @change="loadCustomers" />
<span class="dim small">共 {{ customers.length }} 户</span>
</div>
</div>
</template>
<el-table
:data="customers" size="small" stripe border
:default-sort="{ prop: 'overdue_amt', order: 'descending' }"
@sort-change="onSortChange"
empty-text="该期无数据(不补 0)"
>
<el-table-column prop="unit_code" label="单位编码" width="170" sortable="custom" />
<el-table-column prop="unit_name" label="单位名称" min-width="130" sortable="custom" />
<el-table-column prop="total_amt" label="期末应收合计" width="130" align="right" sortable="custom">
<template #default="{ row }">{{ money(row.total_amt) }}</template>
</el-table-column>
<el-table-column prop="overdue_amt" label="逾期额(&gt;30天)" width="135" align="right" sortable="custom">
<template #default="{ row }">
<span :class="{ 'neg': row.overdue_amt > 0 }">{{ money(row.overdue_amt) }}</span>
</template>
</el-table-column>
<el-table-column prop="pct_of_unit" label="逾期占该户" width="110" align="right">
<template #default="{ row }">{{ pct(row.pct_of_unit) }}</template>
</el-table-column>
<el-table-column prop="oldest_days" label="最老逾期档" width="110" align="center" sortable="custom">
<template #default="{ row }">
<span v-if="row.oldest_days">{{ row.oldest_days }} 天以上</span>
<span v-else class="dim">未逾期</span>
</template>
</el-table-column>
<el-table-column v-for="b in buckets" :key="b.col" :prop="`buckets.${b.col}`"
:label="b.label" width="108" align="right">
<template #default="{ row }">{{ money(row.buckets[b.col]) }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import api, { arAgingApi } from '@/api'
import { sessionEntityName } from '@/utils/entity'
const CALIBERS = ['借方应收户口径', '全户口径', '贷方户单列(净额)']
const BUCKETS = [
{ col: 'b_0_30', label: '0-30' },
{ col: 'b_31_60', label: '31-60' },
{ col: 'b_61_90', label: '61-90' },
{ col: 'b_90_120', label: '90-120' },
{ col: 'b_120_150', label: '120-150' },
{ col: 'b_150p', label: '150+' },
]
const entityLabel = ref(sessionEntityName())
const calibers = ref<string[]>(CALIBERS)
const caliber = ref(CALIBERS[0])
const period = ref('')
const periodOptions = ref<string[]>([])
const meta = ref<any>({ available: null })
const metaLoaded = ref(false)
const overview = ref<any>({})
const thresholds = computed<any>(() => overview.value.thresholds || {})
const trend = ref<any[]>([])
const customers = ref<any[]>([])
const overdueOnly = ref(false)
const loading = ref(false)
const exporting = ref(false)
const sortState = ref<{ sort: string; order: string }>({ sort: 'overdue_amt', order: 'desc' })
const caliberLabel = computed(() => caliber.value)
const displayPeriod = computed(() => overview.value.period || period.value || '')
const buckets = BUCKETS
let trendChart: any = null
let stackChart: any = null
/** 金额格式化:null/undefined ⇒ 该期无数据(绝不显示 0.00) */
function money(v: any): string {
if (v === null || v === undefined || v === '') return '该期无数据'
const n = Number(v)
if (!Number.isFinite(n)) return '该期无数据'
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function pct(v: any): string {
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
return `${(Number(v) * 100).toFixed(2)}%`
}
function pctSigned(v: any): string {
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
const n = Number(v) * 100
return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
}
function momClass(v: any): string {
if (v === null || v === undefined) return 'dim'
return Number(v) > 0 ? 'up' : Number(v) < 0 ? 'down' : 'dim'
}
const shareColor = computed(() => {
const s = overview.value?.share_90plus
const th: any = thresholds.value
if (s === null || s === undefined) return 'inherit'
if (th.share_90plus_red_gt != null && Number(s) > Number(th.share_90plus_red_gt)) return '#f56c6c'
if (th.share_90plus_yellow_min != null && Number(s) >= Number(th.share_90plus_yellow_min)) return '#e6a23c'
return '#67c23a'
})
const dataPoints = computed(() => trend.value.filter(p => p.has_data))
const dataPointCount = computed(() => dataPoints.value.length)
const balancedCount = computed(() => dataPoints.value.filter(p => {
const sum = BUCKETS.reduce((acc, b) => acc + Number(p.buckets?.[b.col] ?? 0), 0)
return Math.abs(sum - Number(p.total_amt ?? 0)) < 0.01
}).length)
const bucketsBalanced = computed(() => dataPointCount.value > 0 && balancedCount.value === dataPointCount.value)
const trendRange = computed(() => {
if (!trend.value.length) return ''
return `${trend.value[0].period} ~ ${trend.value[trend.value.length - 1].period}(${trend.value.length} 期)`
})
async function loadMeta() {
const r: any = await arAgingApi.meta()
meta.value = r || {}
metaLoaded.value = true
if (Array.isArray(r?.calibers) && r.calibers.length) calibers.value = r.calibers
const periods: string[] = Array.isArray(r?.periods_available) ? r.periods_available : []
// 期间候选 = 表内可用期 + 当前月(缺期也要能选中并显「该期无数据」)
const nowPeriod = new Date().toISOString().slice(0, 7)
const opts = [...periods]
if (!opts.includes(nowPeriod)) opts.push(nowPeriod)
periodOptions.value = opts.sort().reverse()
if (!period.value) period.value = r?.latest_period || opts[0] || ''
}
async function loadTrend() {
const r: any = await arAgingApi.trend({ caliber: caliber.value })
trend.value = Array.isArray(r?.points) ? r.points : []
}
async function loadPeriodData() {
loading.value = true
try {
const [ov, cu] = await Promise.all([
arAgingApi.overview({ period: period.value, caliber: caliber.value }),
arAgingApi.customers({
period: period.value, caliber: caliber.value,
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
}),
])
overview.value = (ov as any) || {}
customers.value = Array.isArray((cu as any)?.items) ? (cu as any).items : []
if (!overview.value.period && period.value) overview.value.period = period.value
await nextTick()
renderCharts()
} catch (e: any) {
ElMessage.error(e?.message || '账龄数据加载失败')
} finally {
loading.value = false
}
}
async function loadCustomers() {
const r: any = await arAgingApi.customers({
period: period.value, caliber: caliber.value,
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
})
customers.value = Array.isArray(r?.items) ? r.items : []
}
function onSortChange({ prop, order }: any) {
if (!prop || !order) {
sortState.value = { sort: 'overdue_amt', order: 'desc' }
} else {
sortState.value = { sort: prop, order: order === 'ascending' ? 'asc' : 'desc' }
}
loadCustomers()
}
async function onCaliberChange() {
await Promise.all([loadTrend(), loadPeriodData()])
}
function renderCharts() {
const periods = trend.value.map(p => p.period)
const missing = trend.value.filter(p => !p.has_data).map(p => p.period)
if (trendRef.value) {
trendChart = trendChart || echarts.init(trendRef.value)
trendChart.setOption({
grid: { left: 60, right: 60, top: 40, bottom: 40 },
tooltip: {
trigger: 'axis',
formatter: (ps: any[]) => {
const p = ps[0]?.axisValue
const pt = trend.value.find(t => t.period === p)
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
return `${p}<br/>应收总额:${money(pt.total_amt)}<br/>逾期额(&gt;30天):${money(pt.overdue_amt)}<br/>往来单位:${pt.customers} 户`
},
},
legend: { data: ['应收总额', '往来单位户数'], top: 0 },
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '金额(元)', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
{ type: 'value', name: '户数', minInterval: 1 },
],
series: [
{
name: '应收总额', type: 'line', smooth: false, connectNulls: false,
data: trend.value.map(p => (p.has_data ? p.total_amt : null)),
itemStyle: { color: '#409EFF' }, areaStyle: { opacity: 0.08 },
markArea: missing.length ? {
silent: true,
itemStyle: { color: 'rgba(144,147,153,0.12)' },
label: { show: true, position: 'insideTop', color: '#909399', fontSize: 10, formatter: '该期无数据' },
data: missing.map(p => [{ xAxis: p }, { xAxis: p }]),
} : undefined,
},
{
name: '往来单位户数', type: 'bar', yAxisIndex: 1,
data: trend.value.map(p => (p.has_data ? p.customers : null)),
itemStyle: { color: '#c8d6e5' }, barMaxWidth: 18,
},
],
}, true)
}
if (stackRef.value) {
stackChart = stackChart || echarts.init(stackRef.value)
stackChart.setOption({
grid: { left: 60, right: 24, top: 40, bottom: 40 },
tooltip: {
trigger: 'axis',
formatter: (ps: any[]) => {
const p = ps[0]?.axisValue
const pt = trend.value.find(t => t.period === p)
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
const sum = BUCKETS.reduce((acc, b) => acc + Number(pt.buckets?.[b.col] ?? 0), 0)
const diff = sum - Number(pt.total_amt ?? 0)
const ok = Math.abs(diff) < 0.01
const lines = BUCKETS.map((b, i) => `${b.label}:${money(ps[i]?.value)}`)
return [
`${p}(${caliber.value})`,
...lines,
`六档之和:${money(sum)}`,
`期末合计:${money(pt.total_amt)}`,
ok ? '校验:六档和 = 合计' : `校验异常:差额 ${diff.toFixed(2)}`,
].join('<br/>')
},
},
legend: { data: BUCKETS.map(b => b.label), top: 0, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } },
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
series: BUCKETS.map((b, i) => ({
name: b.label, type: 'bar', stack: 'aging', barMaxWidth: 26,
data: trend.value.map(p => (p.has_data ? p.buckets?.[b.col] : null)),
itemStyle: { color: ['#a3d0a3', '#e8c07d', '#e5a45f', '#e0765f', '#c74b4b', '#8f2f2f'][i] },
})),
}, true)
}
}
async function doExport() {
exporting.value = true
try {
const res: any = await api.get('/ar-aging/customers/export', {
params: {
period: period.value, caliber: caliber.value,
overdue_only: overdueOnly.value,
},
responseType: 'blob',
})
const url = window.URL.createObjectURL(res)
const a = document.createElement('a')
a.href = url
a.download = `应收账龄_${displayPeriod.value || 'noperiod'}_${caliber.value}.csv`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('逐户清单已导出')
} catch (e: any) {
ElMessage.error('导出失败(需 finance/ceo/business 权限且该期有数据)')
} finally {
exporting.value = false
}
}
function onResize() {
trendChart?.resize()
stackChart?.resize()
}
const trendRef = ref<HTMLElement | null>(null)
const stackRef = ref<HTMLElement | null>(null)
onMounted(async () => {
window.addEventListener('resize', onResize)
await loadMeta()
await loadTrend()
await loadPeriodData()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
trendChart?.dispose()
stackChart?.dispose()
})
</script>
<style scoped>
.ar-aging-page { max-width: 1440px; margin: 0 auto; padding: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
.caliber-suffix { color: #409eff; font-size: 16px; font-weight: 500; }
.badge-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.source-note { font-size: 12px; color: #909399; margin: 0; line-height: 1.5; max-width: 720px; }
.head-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.metric { min-height: 108px; }
.metric-label { font-size: 13px; color: #909399; }
.metric-value { font-size: 26px; font-weight: 600; margin: 6px 0; word-break: break-all; }
.metric-sub { font-size: 12px; color: #909399; }
.chart-row { margin-top: 16px; }
.chart-box { height: 320px; }
.chart-note { font-size: 12px; color: #909399; margin: 6px 0 0; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.table-card { margin-top: 16px; }
.table-tools { display: flex; align-items: center; gap: 12px; }
.small { font-size: 12px; }
.dim { color: #909399; }
.up { color: #f56c6c; font-weight: 600; }
.down { color: #67c23a; font-weight: 600; }
.neg { color: #f56c6c; }
.th-red { color: #f56c6c; margin-right: 8px; }
.th-yellow { color: #e6a23c; }
.ico { width: 14px; height: 14px; margin-right: 4px; vertical-align: -2px; }
</style>