feat(alerts): 新增客户集中度预警规则 customer_concentration + 预测偏差规则启用/联动每日预警生成
- alert_rules: 新增 rule_type=customer_concentration(单一/前N客户收入占比超阈值触发,黄=超阈值/红=阈值x倍数) 数据源 zxt_masterbill 客户(往来单位)收入聚合,unit_id<=0 占位剔除;无客户数据可回退 KPI 口径 复用现有 KPIAlert 生成链路:check-all 内联分支 + 专用端点 POST /run-customer-concentration - alert_generator(每日9:00): 新增规则引擎步骤,复用上述检查函数跑 forecast_deviation/customer_concentration - AlertList.vue: 规则类型筛选/编辑项 + 参数表单 + 客户集中度检查按钮 - 测试: tests/test_customer_concentration_rule.py 17 条(规则注册/聚合/双数据源/黄红灯/去重/check-all) - 验证脚本: scripts/verify_forecast_deviation_rules_e2e.py / verify_customer_concentration_rule_e2e.py
This commit is contained in:
@@ -172,6 +172,8 @@ export const alertRulesApi = {
|
||||
dynamicThresholds: (params?: any) => api.get('/alert-rules/dynamic-thresholds', { params }),
|
||||
// AI事前预警
|
||||
checkForecast: () => api.post('/alert-rules/check-forecast'),
|
||||
checkForecastDeviation: () => api.post('/alert-rules/run-forecast-deviation'),
|
||||
checkConcentration: () => api.post('/alert-rules/run-customer-concentration'),
|
||||
generateSuggestions: () => api.post('/alert-rules/generate-suggestions'),
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
<el-option label="动态阈值" value="dynamic" />
|
||||
<el-option label="上升趋势" value="trend_up" />
|
||||
<el-option label="下降趋势" value="trend_down" />
|
||||
<el-option label="预测偏差" value="forecast_deviation" />
|
||||
<el-option label="客户集中度" value="customer_concentration" />
|
||||
</el-select>
|
||||
<el-select v-model="ruleEnabledFilter" placeholder="状态" clearable size="small" style="width:100px">
|
||||
<el-option label="已启用" :value="1" />
|
||||
@@ -152,6 +154,7 @@
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRules">查询</el-button>
|
||||
<el-button size="small" @click="checkForecastAlerts" :loading="fcLoading">🔮 检查预测值</el-button>
|
||||
<el-button size="small" @click="checkConcentrationAlerts" :loading="ccLoading">🏦 检查客户集中度</el-button>
|
||||
<el-button size="small" @click="generateAlertSuggestions" :loading="sgLoading">💡 生成情景建议</el-button>
|
||||
</div>
|
||||
|
||||
@@ -268,6 +271,8 @@
|
||||
<el-option label="动态阈值" value="dynamic" />
|
||||
<el-option label="上升趋势预警" value="trend_up" />
|
||||
<el-option label="下降趋势预警" value="trend_down" />
|
||||
<el-option label="预测偏差预警" value="forecast_deviation" />
|
||||
<el-option label="客户集中度预警" value="customer_concentration" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="触发条件">
|
||||
@@ -298,6 +303,26 @@
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">超过此百分比触发预警</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="ruleForm.rule_type === 'forecast_deviation'">
|
||||
<el-form-item label="偏差阈值(%)">
|
||||
<el-input-number v-model="ruleForm.params.threshold_pct" :min="1" :max="100" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">预测值与该期实际值偏差超过此比例触发(红灯=2倍)</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="ruleForm.rule_type === 'customer_concentration'">
|
||||
<el-form-item label="占比阈值(%)">
|
||||
<el-input-number v-model="ruleForm.params.threshold_pct" :min="1" :max="100" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">单一客户收入占比超过此比例触发(默认50)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="红灯倍数">
|
||||
<el-input-number v-model="ruleForm.params.red_ratio" :min="1" :max="3" :step="0.1" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">占比 ≥ 阈值×倍数 → 红灯(默认1.5)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="前N客户">
|
||||
<el-input-number v-model="ruleForm.params.top_n" :min="1" :max="10" />
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px;">默认1=单一客户占比</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRuleForm=false">取消</el-button>
|
||||
@@ -383,7 +408,7 @@ const ruleForm = ref({
|
||||
rule_type: 'static',
|
||||
trigger_on: 'actual',
|
||||
enabled: 1,
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, red_ratio: 1.5, top_n: 1 },
|
||||
})
|
||||
|
||||
// ── 改善计划 ──
|
||||
@@ -416,10 +441,10 @@ function formatTime(t: string): string {
|
||||
}
|
||||
|
||||
function ruleTypeLabel(type: string): string {
|
||||
return { static: '静态阈值', dynamic: '动态阈值', trend_up: '上升趋势', trend_down: '下降趋势' }[type] || type
|
||||
return { static: '静态阈值', dynamic: '动态阈值', trend_up: '上升趋势', trend_down: '下降趋势', forecast_deviation: '预测偏差', customer_concentration: '客户集中度' }[type] || type
|
||||
}
|
||||
function ruleTypeTag(type: string): string {
|
||||
return { static: 'primary', dynamic: 'success', trend_up: 'warning', trend_down: 'danger' }[type] || 'info'
|
||||
return { static: 'primary', dynamic: 'success', trend_up: 'warning', trend_down: 'danger', forecast_deviation: 'warning', customer_concentration: 'danger' }[type] || 'info'
|
||||
}
|
||||
|
||||
function formatRuleParams(params: any, type: string): string {
|
||||
@@ -428,6 +453,8 @@ function formatRuleParams(params: any, type: string): string {
|
||||
if (type === 'static') return `绿${params.green||'-'} / 黄${params.yellow||'-'} / 红${params.red||'-'}`
|
||||
if (type === 'dynamic') return `敏感度=${params.sensitivity||1.0}`
|
||||
if (type === 'trend_up' || type === 'trend_down') return `阈值>${params.threshold_pct||10}%`
|
||||
if (type === 'forecast_deviation') return `偏差>${params.threshold_pct||15}%`
|
||||
if (type === 'customer_concentration') return `单一客户占比>${params.threshold_pct||50}%(前${params.top_n||1}客户)`
|
||||
return JSON.stringify(params)
|
||||
}
|
||||
|
||||
@@ -549,6 +576,16 @@ async function checkForecastAlerts() {
|
||||
fcLoading.value = false
|
||||
}
|
||||
|
||||
async function checkConcentrationAlerts() {
|
||||
ccLoading.value = true
|
||||
try {
|
||||
const r: any = await alertRulesApi.checkConcentration()
|
||||
ElMessage.success(r.message || '客户集中度检查完成')
|
||||
loadAlerts()
|
||||
} catch (e) { ElMessage.error('检查失败') }
|
||||
ccLoading.value = false
|
||||
}
|
||||
|
||||
async function generateAlertSuggestions() {
|
||||
sgLoading.value = true
|
||||
try {
|
||||
@@ -561,6 +598,7 @@ async function generateAlertSuggestions() {
|
||||
|
||||
const fcLoading = ref(false)
|
||||
const sgLoading = ref(false)
|
||||
const ccLoading = ref(false)
|
||||
|
||||
async function calcDynamic() {
|
||||
calcLoading.value = true
|
||||
|
||||
Reference in New Issue
Block a user