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
+42 -2
View File
@@ -190,6 +190,41 @@ def run_alert_check(db_session, period: str = None) -> int:
return new_count
def run_rule_engine_checks(db_session) -> dict:
"""预警规则引擎(rule_type 驱动)— 预测偏差 + 客户集中度(P2 2026-09-10)
复用 alert_rules 的检查端点实现(FastAPI 路由函数=带 Depends 默认值的普通函数,
显式传参即可复用,零逻辑复制),按存在规则的账套逐个执行。
任何单点失败只记录日志,不影响静态阈值主流程。
"""
from app.api.alert_rules import (
AlertRule, run_forecast_deviation_check, run_customer_concentration_check,
)
stats = {"forecast_deviation": 0, "customer_concentration": 0}
try:
rows = db_session.query(AlertRule.entity_id).filter(
AlertRule.enabled == 1,
AlertRule.rule_type.in_(("forecast_deviation", "customer_concentration")),
).distinct().all()
except Exception as e:
logger.error(f"规则引擎检查跳过(规则查询失败): {e}")
return stats
for (eid,) in rows:
for key, fn in (("forecast_deviation", run_forecast_deviation_check),
("customer_concentration", run_customer_concentration_check)):
try:
r = fn(db=db_session, entity_id=eid)
n = int(r.get("generated", 0) or 0)
stats[key] += n
if n:
logger.info(f" 规则预警 entity={eid} {key}: {n}条")
except Exception as e:
logger.error(f"规则检查失败 entity={eid} {key}: {e}")
return stats
def generate_and_push(db_session) -> dict:
"""生成预警并推送,返回统计"""
period = datetime.now().strftime("%Y-%m")
@@ -202,6 +237,9 @@ def generate_and_push(db_session) -> dict:
new_count = run_alert_check(db_session, period)
logger.info(f"预警检查完成: 新增 {new_count} 条")
# 1b. 规则引擎检查(预测偏差 / 客户集中度,P2 2026-09-10)
rule_stats = run_rule_engine_checks(db_session)
# 2. 推送
pushed = 0
if new_count > 0:
@@ -212,7 +250,8 @@ def generate_and_push(db_session) -> dict:
except Exception as e:
logger.error(f"推送失败: {e}")
return {"period": period, "new_alerts": new_count, "pushed": pushed, "cleaned_green": cleaned}
return {"period": period, "new_alerts": new_count, "pushed": pushed,
"cleaned_green": cleaned, "rule_engine": rule_stats}
if __name__ == "__main__":
@@ -224,6 +263,7 @@ if __name__ == "__main__":
db = get_session_local()()
try:
result = generate_and_push(db)
print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送")
print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送"
f" / 规则引擎{result['rule_engine']}")
finally:
db.close()
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""任务B 交付验收脚本 — customer_concentration 客户集中度预警(生产 E2E)
做四件事(可复跑):
1. 为 entity1 配置常驻客户集中度规则(KPI=C_CUST_CONCENTRATION, source=auto → zxt 客户收入)
2. 打印真实数据下的单一客户占比(zxt_masterbill 近12个月)作为基线
3. 触发测试:建带标记的临时规则(source=kpi)+ 带标记的临时 KPIValue(占比 68%)
→ 调 POST /api/cma/alert-rules/run-customer-concentration → 断言 pending 预警生成
4. 清理临时规则/KPIValue/预警,回查归零
用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_customer_concentration_rule_e2e.py
"""
import json
import sys
import urllib.request
import urllib.error
BASE = "http://127.0.0.1:8010"
ADMIN = {"username": "admin", "password": "admin123"}
MARK = "【系统验证临时数据-验证后删除】"
TEMP_TAG = "e2e-verify-temp"
RULE_TYPE = "customer_concentration"
KPI_ID = 23 # C_CUST_CONCENTRATION 客户集中度 (entity1)
def http(method, path, token=None, body=None, timeout=60):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
method=method,
)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {"detail": e.read().decode()[:300]}
def login(entity_id=1):
st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id})
assert st == 200, f"登录失败: {st} {r}"
return r["token"]
def main():
sys.path.insert(0, ".")
from app.database import get_session_local
from app.models import KPIValue, KPIAlert, KPIDefinition
from app.api.alert_rules import AlertRule, _customer_revenue_shares
token = login(1)
db = get_session_local()()
created_ids = []
try:
# ── 1+2. 常驻规则 + 真实基线 ──────────────────────────────
print("=== 1. 常驻客户集中度规则(entity1)===")
st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
assert st == 200, f"列规则失败: {st} {rules}"
perm = [r for r in rules.get("data", []) if r["kpi_id"] == KPI_ID and r["entity_id"] == 1
and not (r.get("params") or {}).get("_verify")]
if perm:
print(f" 已存在 rule_id={perm[0]['id']}(幂等跳过)")
else:
st, r = http("POST", "/api/cma/alert-rules", token=token, body={
"kpi_id": KPI_ID,
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1, "source": "auto"},
})
assert st == 200, f"建规则失败: {st} {r}"
print(f" 新建 rule_id={r['data']['id']} params={r['data']['params']}")
print("=== 2. 真实数据基线(zxt_masterbill 近12个月, entity1)===")
db.rollback()
shares, total, rng = _customer_revenue_shares(db, 1, lookback_months=12, top_n=5)
assert total > 0, "entity1 无客户收入数据 → 基线取数失败"
print(f" 客户收入合计={total:,.0f}元 ({rng[0]}~{rng[1]}), 客户数={len(shares)}")
for uid, amt, pct in shares:
print(f" 客户#{uid}: {amt:,.0f}元 / {pct}%")
top1 = shares[0][2]
print(f" → 单一客户占比 {top1}%(阈值50% → {'触发' if top1 > 50 else '未触发'})")
# ── 3. 触发测试(临时规则 + 临时KPI值)────────────────────
print("=== 3. 触发测试(构造占比超阈值场景)===")
st, r = http("POST", "/api/cma/alert-rules", token=token, body={
"kpi_id": KPI_ID,
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1,
"source": "kpi", "_verify": TEMP_TAG},
})
assert st == 200, f"建临时规则失败: {st} {r}"
temp_rule_id = r["data"]["id"]
created_ids.append(temp_rule_id)
print(f" 临时规则 rule_id={temp_rule_id} (source=kpi, 阈值50%)")
db.rollback()
temp = KPIValue(kpi_id=KPI_ID, entity_id=1, period="2026-08", actual_value=68.0,
source_type="manual", data_status="pending", remark=MARK)
db.add(temp)
db.commit()
db.refresh(temp)
temp_kv_id = temp.id
print(f" 临时KPIValue id={temp_kv_id} 占比=68.0%")
st, r = http("POST", "/api/cma/alert-rules/run-customer-concentration", token=token)
assert st == 200, f"触发失败: {st} {r}"
print(f" run-customer-concentration → {r}")
db.rollback()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == 1, KPIAlert.kpi_id == KPI_ID,
KPIAlert.alert_type == "concentration", KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).all()
assert alerts, "未生成客户集中度预警 → 触发测试 FAIL"
a = alerts[0]
alert_id = a.id
print(f" ✅ 预警生成: id={a.id} level={a.alert_level} type={a.alert_type}")
print(f" message={a.alert_message}")
assert a.alert_level == "yellow", f"68%应为黄灯(50%~75%), 实际={a.alert_level}"
# ── 4. 清理 ────────────────────────────────────────────
print("=== 4. 清理临时数据(生产不留假数据)===")
db.rollback()
for obj in (db.query(KPIAlert).filter(KPIAlert.id == alert_id).first(),
db.query(KPIValue).filter(KPIValue.id == temp_kv_id).first(),
db.query(AlertRule).filter(AlertRule.id == temp_rule_id).first()):
if obj:
db.delete(obj)
db.commit()
left = {
"alert": db.query(KPIAlert).filter(KPIAlert.id == alert_id).count(),
"kpi_value": db.query(KPIValue).filter(KPIValue.remark == MARK).count(),
"temp_rule": db.query(AlertRule).filter(AlertRule.id == temp_rule_id).count(),
}
print(f" 回查: {left}")
assert all(v == 0 for v in left.values())
# ── 5. 终态 ────────────────────────────────────────────
print("=== 5. 常驻规则终态 ===")
st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
for r in rules.get("data", []):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r["kpi_id"]).first()
print(f" rule_id={r['id']} entity={r['entity_id']} kpi={kpi.kpi_code if kpi else r['kpi_id']}"
f" enabled={r['enabled']} params={r['params']}")
finally:
db.close()
print("\nALL PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""任务A 交付验收脚本 — forecast_deviation 预测偏差规则(酣客entity1 / 博海entity2)
做四件事(可复跑,幂等):
1. 经生产 API(管理员 JWT)为 entity1 / entity2 各建 1 条 forecast_deviation 规则
2. 回查 API 列表 + 直查 DB 实证规则存在(含 rule_id / kpi / params)
3. 触发测试:给 entity1 规则构造一期「预测偏差超阈值」场景(带标记的临时 KPIValue)
→ 调 POST /api/cma/alert-rules/run-forecast-deviation → 断言 pending 预警生成
4. 清理:删除临时 KPIValue 与该次生成的预警,回查归零(生产不留假数据)
用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_forecast_deviation_rules_e2e.py
"""
import json
import sys
import urllib.request
import urllib.error
BASE = "http://127.0.0.1:8010"
ADMIN = {"username": "admin", "password": "admin123"}
MARK = "【系统验证临时数据-验证后删除】"
# 规则目标:酣客 → 应收账款周转天数(F_AR_DAYS, id=6,有 2026-09 预测日志)
# 博海 → 营收(BH_REVENUE, id=421)
TARGETS = [
{"entity_id": 1, "kpi_id": 6, "kpi_label": "F_AR_DAYS"},
{"entity_id": 2, "kpi_id": 421, "kpi_label": "BH_REVENUE"},
]
RULE_TYPE = "forecast_deviation"
PARAMS = {"threshold_pct": 15, "red_ratio": 2} # >15% 黄,>30% 红(实现按 threshold×2 定红)
def http(method, path, token=None, body=None, timeout=30):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {"detail": e.read().decode()[:300]}
def login(entity_id):
st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id})
assert st == 200, f"登录 entity{entity_id} 失败: {st} {r}"
return r["token"], r.get("entity_name")
def main():
ok = True
# ── 1+2. 建规则 ────────────────────────────────────────────
print("=== 1. 创建 forecast_deviation 规则 ===")
created = {}
for t in TARGETS:
eid = t["entity_id"]
token, ename = login(eid)
st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
assert st == 200, f"列规则失败: {st} {rules}"
exist = [r for r in rules.get("data", []) if r["kpi_id"] == t["kpi_id"] and r["rule_type"] == RULE_TYPE]
if exist:
rid = exist[0]["id"]
print(f" entity{eid}({ename}) {t['kpi_label']}: 已存在 rule_id={rid}(幂等跳过)")
else:
st, r = http("POST", "/api/cma/alert-rules", token=token, body={
"kpi_id": t["kpi_id"],
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": PARAMS,
})
assert st == 200, f"建规则失败 entity{eid}: {st} {r}"
rid = r["data"]["id"]
print(f" entity{eid}({ename}) {t['kpi_label']}: 新建 rule_id={rid} params={r['data']['params']}")
created[eid] = rid
# ── 3. DB 回查 ─────────────────────────────────────────────
print("=== 2. DB 回查实证 ===")
sys.path.insert(0, ".")
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, KpiForecastLog
from app.api.alert_rules import AlertRule
db = get_session_local()()
try:
for eid, rid in created.items():
row = db.query(AlertRule).filter(AlertRule.id == rid).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == row.kpi_id).first()
print(f" rule_id={row.id} entity_id={row.entity_id} kpi_id={row.kpi_id}"
f"({kpi.kpi_code if kpi else '?'}) type={row.rule_type} enabled={row.enabled} params={row.params}")
assert row.entity_id == eid and row.rule_type == RULE_TYPE
assert row.enabled == 1
# ── 4. 触发测试(entity1)─────────────────────────────
print("=== 3. 触发测试(构造偏差场景, entity1)===")
t1 = TARGETS[0]
rid = created[1]
rule = db.query(AlertRule).filter(AlertRule.id == rid).first()
log = (db.query(KpiForecastLog)
.filter(KpiForecastLog.entity_id == 1, KpiForecastLog.kpi_id == rule.kpi_id)
.order_by(KpiForecastLog.created_at.desc()).first())
forecast = float(log.forecast_value)
# 构造:实际值 = 预测值的一半 → 偏差 100% > 15% → red(>30%)
actual = round(forecast / 2, 2)
temp = KPIValue(kpi_id=rule.kpi_id, entity_id=1, period=log.period,
actual_value=actual, source_type="manual", data_status="pending", remark=MARK)
db.add(temp)
db.commit()
db.refresh(temp)
print(f" 临时KPIValue id={temp.id} period={log.period} 预测={forecast} 实际={actual}")
token1, _ = login(1)
st, r = http("POST", "/api/cma/alert-rules/run-forecast-deviation", token=token1, timeout=60)
assert st == 200, f"触发失败: {st} {r}"
print(f" run-forecast-deviation → {r}")
db.expire_all()
# MySQL REPEATABLE READ:API 在另一进程提交,本 session 需结束旧事务快照才能读到
db.rollback()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == 1, KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_type == "forecast", KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).all()
assert alerts, "未生成预测偏差预警 → 触发测试 FAIL"
a = alerts[0]
alert_id = a.id
print(f" ✅ 预警生成: id={a.id} level={a.alert_level} type={a.alert_type}")
print(f" message={a.alert_message}")
assert a.alert_level == "red", f"偏差100%应为红灯, 实际={a.alert_level}"
# ── 5. 清理 ────────────────────────────────────────────
print("=== 4. 清理临时数据(生产不留假数据)===")
temp_id = temp.id
db.rollback()
a_row = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
temp_row = db.query(KPIValue).filter(KPIValue.id == temp_id).first()
if a_row:
db.delete(a_row)
if temp_row:
db.delete(temp_row)
db.commit()
left_v = db.query(KPIValue).filter(KPIValue.remark == MARK).count()
left_a = db.query(KPIAlert).filter(KPIAlert.id == alert_id).count()
print(f" 回查: 临时KPIValue剩余={left_v} / 本次预警剩余={left_a}")
assert left_v == 0 and left_a == 0
print("=== 5. 规则终态(交付物保留)===")
for eid, rid in created.items():
row = db.query(AlertRule).filter(AlertRule.id == rid).first()
print(f" entity{eid}: rule_id={row.id} kpi_id={row.kpi_id} type={row.rule_type} enabled={row.enabled}")
finally:
db.close()
print("\nALL PASS" if ok else "\nFAILED")
return 0
if __name__ == "__main__":
sys.exit(main())