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:
Hermes CI Fix
2026-09-10 09:56:50 +08:00
parent f8688713db
commit 9822fbd9ba
7 changed files with 866 additions and 8 deletions
+2
View File
@@ -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'),
}
+41 -3
View File
@@ -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