feat(data-quality): anomaly-count drilldown to full detail + real COUNT(*) caliber
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
backend: GET /api/cma/data-quality/rule-detail (COUNT(*) total, structured items, server-side paging, scope=manual); governance seven-rule counts use real totals instead of len(LIMIT-ed rows). frontend: clickable anomaly count -> MyDialog detail (no new el-dialog) + full CSV export.
This commit is contained in:
+303
-35
@@ -376,6 +376,244 @@ RULES_META = {
|
||||
|
||||
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
|
||||
|
||||
# ============================================================
|
||||
# rule-detail(异常数可点击 → 全量结构化明细)— 2026-09-23 P1
|
||||
# 口径:total 一律取 COUNT(*)(真实总数,不受明细 LIMIT 影响);
|
||||
# items 结构化 + 服务端分页(LIMIT/OFFSET,单次响应不塞全量)。
|
||||
# 判据与 _run_governance_checks 逐字一致 —— 本单只改聚合口径,
|
||||
# 不改阈值/级别/扣分公式(配置化是第2步,不在本单)。
|
||||
# ============================================================
|
||||
RULE_SOURCES = {
|
||||
"unit_check": "cash_plans",
|
||||
"dup_alert": "kpi_alerts",
|
||||
"orphan_check": "kpi_alerts",
|
||||
"virtual_pollution": "cash_plans",
|
||||
"entity_check": "kpi_values",
|
||||
"kpi_completeness": "kpi_definitions",
|
||||
"reconciliation": "kpi_definitions",
|
||||
}
|
||||
|
||||
RULE_ACTIONS = {
|
||||
"unit_check": "核对金额单位:确为元则改按万元口径录入,或修正计划金额(阈值 >10000 不变)",
|
||||
"dup_alert": "同一 plan_id 只保留一条 pending 预警,其余重复项关闭(去重键错误)",
|
||||
"orphan_check": "确认预警指向的 cash_plans 是否已删除:补建计划或关闭该预警",
|
||||
"virtual_pollution": "测试/虚拟来源计划不得混入真实数据:归档或清除后重跑本规则",
|
||||
"entity_check": "修正 kpi_values.entity_id,使其与 kpi_definitions.entity_id 一致",
|
||||
"kpi_completeness": "为无实际值的 active KPI 补录数据,或按实际情况停用该 KPI",
|
||||
"reconciliation": "核对预算月度合计与年度目标口径,修正差异 >20% 的预算行",
|
||||
}
|
||||
|
||||
|
||||
def _plan_id_set(db: Session, eid: int):
|
||||
"""已存在的 cash_plans id 集合(规则3 判孤儿用;eid 非 0 时只看该实体)"""
|
||||
sql = "SELECT id FROM cash_plans" + (" WHERE entity_id = :eid" if eid else "")
|
||||
return {row[0] for row in db.execute(text(sql), {"eid": eid}).fetchall()}
|
||||
|
||||
|
||||
def _pending_alert_plan_links(db: Session, eid: int):
|
||||
"""规则2/3 共用数据:pending cash_plan 预警 → [(alert_id, kpi_id, plan_id, kpi_entity_id)]
|
||||
|
||||
返回 (parsed, raw_rows);Python 侧解析 suggestion JSON(不依赖 MySQL JSON 函数)。
|
||||
"""
|
||||
alert_sql = (
|
||||
"SELECT a.id, a.kpi_id, a.suggestion, d.entity_id AS kpi_entity_id "
|
||||
"FROM kpi_alerts a JOIN kpi_definitions d ON d.id = a.kpi_id "
|
||||
"WHERE a.alert_type = 'cash_plan' AND a.status = 'pending'"
|
||||
)
|
||||
if eid:
|
||||
alert_sql += " AND d.entity_id = :eid"
|
||||
rows = db.execute(text(alert_sql), {"eid": eid}).fetchall()
|
||||
parsed = []
|
||||
for r in rows:
|
||||
pid = _extract_plan_id(r.suggestion)
|
||||
if pid is not None:
|
||||
parsed.append((r.id, r.kpi_id, pid, r.kpi_entity_id))
|
||||
return parsed, rows
|
||||
|
||||
|
||||
def _dup_groups(parsed):
|
||||
"""规则2 判据:同 plan_id 多条 pending 预警 → [(plan_id, [(alert_id, kpi_id, ent_id), ...])],按条数降序"""
|
||||
group_map = {}
|
||||
for alert_id, kpi_id, pid, ent_id in parsed:
|
||||
group_map.setdefault(pid, []).append((alert_id, kpi_id, ent_id))
|
||||
groups = [(pid, ids) for pid, ids in group_map.items() if len(ids) > 1]
|
||||
groups.sort(key=lambda x: -len(x[1]))
|
||||
return groups
|
||||
|
||||
|
||||
def _detail_count(db: Session, rule: str, eid: int, manual: bool = False) -> int:
|
||||
"""本规则真实总数:一律 COUNT(*)(dup/orphan 由预警分组得出,同样不受 LIMIT 影响)"""
|
||||
if rule == "dup_alert":
|
||||
parsed, _ = _pending_alert_plan_links(db, eid)
|
||||
return len(_dup_groups(parsed))
|
||||
if rule == "orphan_check":
|
||||
parsed, _ = _pending_alert_plan_links(db, eid)
|
||||
pset = _plan_id_set(db, eid)
|
||||
return len([1 for _a, _k, pid, _e in parsed if pid not in pset])
|
||||
|
||||
ent = " AND cp.entity_id = :eid" if eid else ""
|
||||
if rule == "unit_check":
|
||||
sql = "SELECT COUNT(*) FROM cash_plans cp WHERE cp.amount > 10000" + ent
|
||||
elif rule == "virtual_pollution":
|
||||
if manual:
|
||||
sql = "SELECT COUNT(*) FROM cash_plans cp WHERE cp.source = 'manual'" + ent
|
||||
else:
|
||||
sql = ("SELECT COUNT(*) FROM cash_plans cp WHERE (cp.source LIKE '%test%' "
|
||||
"OR cp.source LIKE '%sync%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%')" + ent)
|
||||
elif rule == "entity_check":
|
||||
sql = ("SELECT COUNT(*) FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
|
||||
"WHERE v.entity_id IS NOT NULL AND v.entity_id != d.entity_id")
|
||||
if eid:
|
||||
sql += " AND v.entity_id = :eid"
|
||||
elif rule == "kpi_completeness":
|
||||
sql = ("SELECT COUNT(*) FROM kpi_definitions d WHERE d.status='active' "
|
||||
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id)")
|
||||
if eid:
|
||||
sql += " AND d.entity_id = :eid"
|
||||
elif rule == "reconciliation":
|
||||
sql = ("SELECT COUNT(*) FROM (SELECT d.id, "
|
||||
"ROUND((COALESCE(SUM(b.budget_value),0) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
|
||||
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
|
||||
"WHERE d.status='active' AND d.target_yearly > 0 AND b.status='active'")
|
||||
if eid:
|
||||
sql += " AND d.entity_id = :eid"
|
||||
sql += " GROUP BY d.id HAVING ABS(diff_pct) > 20) t"
|
||||
else:
|
||||
return 0
|
||||
return int(db.execute(text(sql), {"eid": eid}).scalar() or 0)
|
||||
|
||||
|
||||
def _detail_items(db: Session, rule: str, eid: int, limit: int, offset: int, manual: bool = False):
|
||||
"""结构化明细条目(服务端 LIMIT/OFFSET 分页;字段:record_id/entity_id/source/key_fields/summary/suggested_action)"""
|
||||
if rule == "dup_alert":
|
||||
parsed, _ = _pending_alert_plan_links(db, eid)
|
||||
items = []
|
||||
for pid, alerts in _dup_groups(parsed):
|
||||
items.append({
|
||||
"record_id": pid,
|
||||
"entity_id": alerts[0][2],
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"plan_id": pid, "pending_alert_count": len(alerts),
|
||||
"alert_ids": [a[0] for a in alerts]},
|
||||
"summary": "计划#%s 存在 %d 条 pending 应收预警(去重键错误)" % (pid, len(alerts)),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
})
|
||||
return items[offset:offset + limit]
|
||||
|
||||
if rule == "orphan_check":
|
||||
parsed, _ = _pending_alert_plan_links(db, eid)
|
||||
pset = _plan_id_set(db, eid)
|
||||
items = []
|
||||
for alert_id, kpi_id, pid, ent_id in parsed:
|
||||
if pid in pset:
|
||||
continue
|
||||
items.append({
|
||||
"record_id": alert_id,
|
||||
"entity_id": ent_id,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"alert_id": alert_id, "kpi_id": kpi_id, "plan_id": pid},
|
||||
"summary": "预警#%d(kpi#%d) 指向的 plan#%s 不存在" % (alert_id, kpi_id, pid),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
})
|
||||
return items[offset:offset + limit]
|
||||
|
||||
params = {"eid": eid, "lim": limit, "off": offset}
|
||||
ent = " AND cp.entity_id = :eid" if eid else ""
|
||||
|
||||
if rule == "unit_check":
|
||||
rows = db.execute(text(
|
||||
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description FROM cash_plans cp "
|
||||
"WHERE cp.amount > 10000" + ent + " ORDER BY cp.amount DESC LIMIT :lim OFFSET :off"
|
||||
), params).fetchall()
|
||||
return [{
|
||||
"record_id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"amount": float(r.amount) if r.amount is not None else None,
|
||||
"source": r.source, "description": r.description,
|
||||
"threshold": ">10000(万元口径可疑)"},
|
||||
"summary": "计划#%d 金额 %s(超 10000 阈值,疑似单位错乱)" % (r.id, r.amount),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
} for r in rows]
|
||||
|
||||
if rule == "virtual_pollution":
|
||||
if manual:
|
||||
where = "cp.source = 'manual'"
|
||||
else:
|
||||
where = ("(cp.source LIKE '%test%' OR cp.source LIKE '%sync%' "
|
||||
"OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%')")
|
||||
rows = db.execute(text(
|
||||
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
|
||||
"WHERE " + where + ent + " ORDER BY cp.id LIMIT :lim OFFSET :off"
|
||||
), params).fetchall()
|
||||
return [{
|
||||
"record_id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"source": r.source, "description": r.description,
|
||||
"scope": "manual(待人工确认)" if manual else "污染源(test/sync/虚拟/demo)"},
|
||||
"summary": "计划#%d source=%s%s" % (r.id, r.source, "" if not manual else "(待人工确认)"),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
} for r in rows]
|
||||
|
||||
if rule == "entity_check":
|
||||
sql = ("SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
|
||||
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
|
||||
"WHERE v.entity_id IS NOT NULL AND v.entity_id != d.entity_id")
|
||||
if eid:
|
||||
sql += " AND v.entity_id = :eid"
|
||||
sql += " ORDER BY v.id LIMIT :lim OFFSET :off"
|
||||
rows = db.execute(text(sql), params).fetchall()
|
||||
return [{
|
||||
"record_id": r.id,
|
||||
"entity_id": r.v_eid,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"kpi_id": r.kpi_id, "kpi_code": r.kpi_code,
|
||||
"value_entity_id": r.v_eid, "definition_entity_id": r.d_eid},
|
||||
"summary": "值#%d %s:实体%s ≠ 定义实体%s" % (r.id, r.kpi_code, r.v_eid, r.d_eid),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
} for r in rows]
|
||||
|
||||
if rule == "kpi_completeness":
|
||||
sql = ("SELECT d.id, d.entity_id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
|
||||
"WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id)")
|
||||
if eid:
|
||||
sql += " AND d.entity_id = :eid"
|
||||
sql += " ORDER BY d.id LIMIT :lim OFFSET :off"
|
||||
rows = db.execute(text(sql), params).fetchall()
|
||||
return [{
|
||||
"record_id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"kpi_code": r.kpi_code, "kpi_name": r.kpi_name, "status": "active"},
|
||||
"summary": "%s %s(active 但无任何实际值)" % (r.kpi_code, r.kpi_name),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
} for r in rows]
|
||||
|
||||
if rule == "reconciliation":
|
||||
sql = ("SELECT d.id, d.entity_id, d.kpi_code, d.kpi_name, d.target_yearly, "
|
||||
"COALESCE(SUM(b.budget_value),0) AS monthly_sum, "
|
||||
"ROUND((COALESCE(SUM(b.budget_value),0) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
|
||||
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
|
||||
"WHERE d.status='active' AND d.target_yearly > 0 AND b.status='active'")
|
||||
if eid:
|
||||
sql += " AND d.entity_id = :eid"
|
||||
sql += " GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT :lim OFFSET :off"
|
||||
rows = db.execute(text(sql), params).fetchall()
|
||||
return [{
|
||||
"record_id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"source": RULE_SOURCES[rule],
|
||||
"key_fields": {"kpi_code": r.kpi_code, "monthly_sum": float(r.monthly_sum or 0),
|
||||
"target_yearly": float(r.target_yearly or 0), "diff_pct": float(r.diff_pct or 0)},
|
||||
"summary": "%s 预算合计%s vs 年度目标%s 差异%s%%" % (
|
||||
r.kpi_code, round(r.monthly_sum or 0, 1), r.target_yearly, r.diff_pct),
|
||||
"suggested_action": RULE_ACTIONS[rule],
|
||||
} for r in rows]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
|
||||
@@ -413,40 +651,22 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
|
||||
"FROM cash_plans cp WHERE cp.amount > 10000" + ent + " ORDER BY cp.amount DESC LIMIT 200"
|
||||
), {"eid": eid}).fetchall()
|
||||
unit_total = _detail_count(db, "unit_check", eid) # 真实总数(不再受明细 LIMIT 200 截断)
|
||||
issues.append({
|
||||
"rule": "unit_check", "level": "error",
|
||||
"count": len(rows),
|
||||
"count": unit_total,
|
||||
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||
+ (["…等%d条" % unit_total] if unit_total > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则2/3 共用数据:pending cash_plan 预警(实体筛选经 kpi_definitions)──
|
||||
alert_sql = (
|
||||
"SELECT a.id, a.kpi_id, a.suggestion, d.entity_id AS kpi_entity_id "
|
||||
"FROM kpi_alerts a JOIN kpi_definitions d ON d.id = a.kpi_id "
|
||||
"WHERE a.alert_type = 'cash_plan' AND a.status = 'pending'"
|
||||
)
|
||||
if eid:
|
||||
alert_sql += " AND d.entity_id = :eid"
|
||||
alert_rows = db.execute(text(alert_sql), {"eid": eid}).fetchall()
|
||||
|
||||
# Python 侧解析 suggestion → plan_id(不依赖 MySQL JSON 函数)
|
||||
parsed = [] # [(alert_id, kpi_id, plan_id)]
|
||||
for r in alert_rows:
|
||||
pid = _extract_plan_id(r.suggestion)
|
||||
if pid is not None:
|
||||
parsed.append((r.id, r.kpi_id, pid))
|
||||
parsed, alert_rows = _pending_alert_plan_links(db, eid)
|
||||
|
||||
# 已存在的 cash_plans id 集合(规则3 判断孤儿用;实体筛选时仅看该实体下 plan)
|
||||
plan_ids_sql = "SELECT id FROM cash_plans" + (" WHERE entity_id = :eid" if eid else "")
|
||||
plan_id_set = {row[0] for row in db.execute(text(plan_ids_sql), {"eid": eid}).fetchall()}
|
||||
plan_id_set = _plan_id_set(db, eid)
|
||||
|
||||
# ── 规则2 重复预警:同 plan_id 多条 pending 预警 ──
|
||||
group_map = {}
|
||||
for alert_id, kpi_id, pid in parsed:
|
||||
group_map.setdefault(pid, []).append(alert_id)
|
||||
dup_groups = [(pid, ids) for pid, ids in group_map.items() if len(ids) > 1]
|
||||
dup_groups.sort(key=lambda x: -len(x[1]))
|
||||
dup_groups = _dup_groups(parsed)
|
||||
issues.append({
|
||||
"rule": "dup_alert", "level": "error",
|
||||
"count": len(dup_groups),
|
||||
@@ -455,7 +675,7 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
})
|
||||
|
||||
# ── 规则3 孤儿预警:plan_id 指向不存在的 cash_plans ──
|
||||
orphan_rows = [(aid, kid, pid) for aid, kid, pid in parsed if pid not in plan_id_set]
|
||||
orphan_rows = [(aid, kid, pid) for aid, kid, pid, _ent in parsed if pid not in plan_id_set]
|
||||
issues.append({
|
||||
"rule": "orphan_check", "level": "error",
|
||||
"count": len(orphan_rows),
|
||||
@@ -473,14 +693,16 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
|
||||
"WHERE cp.source = 'manual'" + ent + " ORDER BY cp.id LIMIT 200"
|
||||
), {"eid": eid}).fetchall()
|
||||
poll_total = _detail_count(db, "virtual_pollution", eid) # 真实总数(不再受 LIMIT 200 截断)
|
||||
manual_total = _detail_count(db, "virtual_pollution", eid, manual=True)
|
||||
issues.append({
|
||||
"rule": "virtual_pollution", "level": "error",
|
||||
"count": len(rows),
|
||||
"count": poll_total,
|
||||
"detail": [f"plan#{r.id} source={r.source} desc={r.description or ''}" for r in rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
|
||||
"manual_count": len(manual_rows),
|
||||
+ (["…等%d条" % poll_total] if poll_total > DETAIL_LIMIT else []),
|
||||
"manual_count": manual_total,
|
||||
"manual_detail": [f"plan#{r.id} source=manual(待人工确认)" for r in manual_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(manual_rows)] if len(manual_rows) > DETAIL_LIMIT else []),
|
||||
+ (["…等%d条" % manual_total] if manual_total > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则5 实体归属:kpi_values.entity_id IS NOT NULL 且 != kpi_definitions.entity_id ──
|
||||
@@ -493,11 +715,12 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
ent_sql += " AND v.entity_id = :eid"
|
||||
ent_sql += " ORDER BY v.id LIMIT 200"
|
||||
ent_rows = db.execute(text(ent_sql), {"eid": eid}).fetchall()
|
||||
ent_total = _detail_count(db, "entity_check", eid) # 真实总数
|
||||
issues.append({
|
||||
"rule": "entity_check", "level": "error",
|
||||
"count": len(ent_rows),
|
||||
"count": ent_total,
|
||||
"detail": [f"值#{r.id} {r.kpi_code} 实体{r.v_eid}≠定义实体{r.d_eid}" for r in ent_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d条" % len(ent_rows)] if len(ent_rows) > DETAIL_LIMIT else []),
|
||||
+ (["…等%d条" % ent_total] if ent_total > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则6 KPI完整性:active KPI 无任何实际值 ──
|
||||
@@ -509,11 +732,12 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
comp_sql += " AND d.entity_id = :eid"
|
||||
comp_sql += " ORDER BY d.id LIMIT 300"
|
||||
comp_rows = db.execute(text(comp_sql), {"eid": eid}).fetchall()
|
||||
comp_total = _detail_count(db, "kpi_completeness", eid) # 真实总数(不再受 LIMIT 300 截断)
|
||||
issues.append({
|
||||
"rule": "kpi_completeness", "level": "warning",
|
||||
"count": len(comp_rows),
|
||||
"count": comp_total,
|
||||
"detail": [f"{r.kpi_code} {r.kpi_name}(无值)" for r in comp_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d个KPI" % len(comp_rows)] if len(comp_rows) > DETAIL_LIMIT else []),
|
||||
+ (["…等%d个KPI" % comp_total] if comp_total > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 规则7 勾稽验证:预算(active口径)年度合计 vs 年度目标 差异>20% ──
|
||||
@@ -528,11 +752,12 @@ def _run_governance_checks(db: Session, entity_id: int = 0):
|
||||
recon_sql += " AND d.entity_id = :eid"
|
||||
recon_sql += " GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
|
||||
recon_rows = db.execute(text(recon_sql), {"eid": eid}).fetchall()
|
||||
recon_total = _detail_count(db, "reconciliation", eid) # 真实总数
|
||||
issues.append({
|
||||
"rule": "reconciliation", "level": "warning",
|
||||
"count": len(recon_rows),
|
||||
"count": recon_total,
|
||||
"detail": [f"{r.kpi_code} 预算合计{round(r.monthly_sum, 1)} vs 年度目标{r.target_yearly} 差异{r.diff_pct}%" for r in recon_rows[:DETAIL_LIMIT]]
|
||||
+ (["…等%d个KPI" % len(recon_rows)] if len(recon_rows) > DETAIL_LIMIT else []),
|
||||
+ (["…等%d个KPI" % recon_total] if recon_total > DETAIL_LIMIT else []),
|
||||
})
|
||||
|
||||
# ── 评分:error 扣 min(15, count*3),warning 扣 min(10, count*1);规则4 manual 按 warning 附加扣 ──
|
||||
@@ -586,3 +811,46 @@ def governance_check(
|
||||
# Bot 通道(无 token):保持原口径(缺省 0=全部)
|
||||
scope = 0 if entity_id is None else entity_id
|
||||
return _run_governance_checks(db, scope)
|
||||
|
||||
|
||||
@router.get("/rule-detail")
|
||||
def rule_detail(
|
||||
rule: Optional[str] = Query(None, description="七规则 key 之一;缺省/空 → 空集(不 500)"),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数,默认 50,上限 200"),
|
||||
scope: Optional[str] = Query(None, description="virtual_pollution 专用:manual=source='manual' 待人工确认组"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""异常明细下钻(只读,2026-09-23 P1「异常数可点击看全量」):
|
||||
|
||||
- `total` 一律来自 COUNT(*)(真实总数,不受明细查询 LIMIT 影响);
|
||||
- `items` 结构化字段:record_id / entity_id / source / key_fields / summary / suggested_action;
|
||||
- 服务端分页(LIMIT/OFFSET),单次响应不塞全量;
|
||||
- 账套由会话 token 解析(Depends(get_entity_id)),**不新增账套入参**;
|
||||
- 兼容:rule 缺省/空 → 空集(200,不 500);未知 rule → 400 + 明确文案。
|
||||
"""
|
||||
key = (rule or "").strip()
|
||||
if not key:
|
||||
return {"rule": "", "level": "", "name": "", "desc": "",
|
||||
"scope": None, "total": 0, "page": page, "page_size": page_size, "items": []}
|
||||
if key not in RULES_META:
|
||||
raise HTTPException(400, f"未知规则 rule={key};可选: {', '.join(RULES_META.keys())}")
|
||||
|
||||
manual = key == "virtual_pollution" and (scope or "").strip().lower() == "manual"
|
||||
eid = entity_id or 0
|
||||
total = _detail_count(db, key, eid, manual=manual)
|
||||
offset = (page - 1) * page_size
|
||||
items = _detail_items(db, key, eid, page_size, offset, manual=manual)
|
||||
return {
|
||||
"rule": key,
|
||||
"level": RULES_META[key]["level"],
|
||||
"name": RULES_META[key]["name"],
|
||||
"desc": RULES_META[key]["desc"],
|
||||
"scope": "manual" if manual else None,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user