feat: DAMA数据治理规则检查(财务七规则)— 后端/check-governance + 前端数据质量中心升级

后端 backend/app/api/data_quality.py:
- 新增 GET /api/cma/data-quality/check-governance?entity_id= 端点
- 7条规则: unit_check单位校验/dup_alert重复预警/orphan_check孤儿预警/virtual_pollution虚拟污染/entity_check实体归属/kpi_completeness KPI完整性/reconciliation勾稽验证
- 质量评分: error扣10/warning扣5, 每条规则最多扣一次, 最低0分
- 兼容实体筛选(0全部/1酣客/2博海), 向后兼容既有/check端点

前端 frontend/src/api/index.ts + views/DataQuality.vue:
- dataQualityApi.checkGovernance() 新增
- 页面新增治理规则区: 质量评分仪表盘 + 7规则红黄绿状态表 + 异常明细展开 + 重新检查按钮 + 实体筛选
This commit is contained in:
Hermes CI Fix
2026-08-30 07:26:59 +08:00
parent 240a9fa899
commit c36ed35348
3 changed files with 325 additions and 1 deletions
+195 -1
View File
@@ -3,7 +3,7 @@
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, and_ from sqlalchemy import func, and_, text
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
@@ -322,3 +322,197 @@ def quality_stats(db: Session = Depends(get_db)):
"threshold_days": 180, "threshold_days": 180,
}, },
} }
# ============================================================
# DAMA 数据治理规则检查(财务七规则)— 2026-08-30 P1
# ============================================================
RULES_META = {
"unit_check": {"name": "单位校验", "level": "error", "desc": "cash_plans.amount > 10000(万元口径可疑,疑似单位错乱)"},
"dup_alert": {"name": "重复预警", "level": "error", "desc": "同一plan_id存在多条pending应收预警(去重键错误)"},
"orphan_check": {"name": "孤儿预警", "level": "error", "desc": "预警suggestion.plan_id指向不存在的cash_plans记录"},
"virtual_pollution": {"name": "虚拟污染", "level": "error", "desc": "cash_plans.source含test/虚拟等测试标识混入真实数据"},
"entity_check": {"name": "实体归属", "level": "error", "desc": "kpi_values.entity_id与kpi_definitions.entity_id不一致"},
"kpi_completeness": {"name": "KPI完整性", "level": "warning", "desc": "active状态KPI无任何实际值的数量"},
"reconciliation": {"name": "勾稽验证", "level": "warning", "desc": "预算月度合计 vs 年度目标差异>20%"},
}
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
def _run_rule_checks(db: Session, entity_id: int = 0):
"""执行7条DAMA治理规则,返回 issues 列表。entity_id=0 表示全部实体。"""
entity_filter = " AND cp.entity_id = :eid" if entity_id else ""
issues = []
# ── 规则1 单位校验 ──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
"FROM cash_plans cp WHERE cp.amount > 10000" + entity_filter + " ORDER BY cp.amount DESC"
), {"eid": entity_id}).fetchall()
issues.append({
"rule": "unit_check", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
})
# ── 规则2 重复预警(同plan_id多条pending应收预警)──
if entity_id:
dup_sql = text(
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' AND p.entity_id = :eid "
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
)
else:
dup_sql = text(
"SELECT JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid, COUNT(*) c, MAX(p.entity_id) eid "
"FROM kpi_alerts a JOIN cash_plans p ON p.id = JSON_EXTRACT(a.suggestion, '$.plan_id') "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' "
"GROUP BY pid HAVING c > 1 ORDER BY c DESC"
)
dup_rows = db.execute(dup_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "dup_alert", "level": "error",
"count": len(dup_rows),
"detail": [f"plan#{r.pid} 重复预警×{r.c}" for r in dup_rows[:DETAIL_LIMIT]]
+ (["…等%d个plan" % len(dup_rows)] if len(dup_rows) > DETAIL_LIMIT else []),
})
# ── 规则3 孤儿预警(plan_id指向不存在的cash_plans)──
orphan_sql = text(
"SELECT a.id, a.kpi_id, JSON_EXTRACT(a.suggestion, '$.plan_id') AS pid "
"FROM kpi_alerts a "
"WHERE a.alert_type='cash_plan' AND a.status='pending' AND JSON_VALID(a.suggestion) "
"AND a.suggestion LIKE '%plan_id%' "
"AND NOT EXISTS (SELECT 1 FROM cash_plans p WHERE p.id = JSON_EXTRACT(a.suggestion, '$.plan_id')) "
"ORDER BY a.id LIMIT 200"
)
orphan_rows = db.execute(orphan_sql).fetchall()
issues.append({
"rule": "orphan_check", "level": "error",
"count": len(orphan_rows),
"detail": [f"预警#{r.id}(kpi#{r.kpi_id}) → plan#{r.pid} 不存在" for r in orphan_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
})
# ── 规则4 虚拟污染(source含test/虚拟标识)──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
"WHERE cp.source LIKE '%test%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%'"
+ entity_filter + " ORDER BY cp.id LIMIT 200"
), {"eid": entity_id}).fetchall()
issues.append({
"rule": "virtual_pollution", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} source={r.source}" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
})
# ── 规则5 实体归属(kpi_values.entity_id != kpi_definitions.entity_id)──
if entity_id:
ent_sql = text(
"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 != d.entity_id AND v.entity_id = :eid ORDER BY v.id LIMIT 200"
)
else:
ent_sql = text(
"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 != d.entity_id ORDER BY v.id LIMIT 200"
)
ent_rows = db.execute(ent_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "entity_check", "level": "error",
"count": len(ent_rows),
"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 []),
})
# ── 规则6 KPI完整性(active KPI无任何值)──
if entity_id:
comp_sql = text(
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
"WHERE d.status='active' AND d.entity_id = :eid "
"AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id) ORDER BY d.id LIMIT 300"
)
else:
comp_sql = text(
"SELECT d.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) ORDER BY d.id LIMIT 300"
)
comp_rows = db.execute(comp_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "kpi_completeness", "level": "warning",
"count": len(comp_rows),
"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 []),
})
# ── 规则7 勾稽验证(预算月度合计 vs 年度目标差异>20%)──
if entity_id:
recon_sql = text(
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
"SUM(b.budget_value) AS monthly_sum, "
"ROUND((SUM(b.budget_value) - 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 d.entity_id = :eid "
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
)
else:
recon_sql = text(
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
"SUM(b.budget_value) AS monthly_sum, "
"ROUND((SUM(b.budget_value) - 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 "
"GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
)
recon_rows = db.execute(recon_sql, {"eid": entity_id}).fetchall()
issues.append({
"rule": "reconciliation", "level": "warning",
"count": len(recon_rows),
"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 []),
})
return issues
@router.get("/check-governance")
def check_governance(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
db: Session = Depends(get_db),
):
"""DAMA数据治理规则检查(财务七规则)— 返回质量评分+异常清单。
评分规则: 满分100,error级规则每条扣10分,warning级规则每条扣5分,
每条规则最多扣一次分(按规则是否命中,不按count累扣),最低0分。
"""
issues = _run_rule_checks(db, entity_id or 0)
# 计算评分
score = 100
for item in issues:
if item["count"] > 0:
score -= 10 if item["level"] == "error" else 5
score = max(0, score)
passed = [item["rule"] for item in issues if item["count"] == 0]
return {
"checked_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": entity_id or 0,
"score": score,
"total_rules": len(issues),
"issues": issues,
"passed": passed,
"rules_meta": RULES_META,
}
+2
View File
@@ -344,6 +344,8 @@ export const dataQualityApi = {
logs: (params?: any) => api.get('/data-quality/logs', { params }), logs: (params?: any) => api.get('/data-quality/logs', { params }),
updateLog: (id: number, data: any) => api.put(`/data-quality/logs/${id}`, data), updateLog: (id: number, data: any) => api.put(`/data-quality/logs/${id}`, data),
deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`), deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`),
// DAMA数据治理规则检查(财务七规则)
checkGovernance: (params?: any) => api.get('/data-quality/check-governance', { params }),
} }
export const biReportApi = { export const biReportApi = {
+128
View File
@@ -121,6 +121,80 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- DAMA 数据治理规则检查财务七规则 -->
<el-card shadow="never" class="section-gap">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>🛡 DAMA数据治理规则检查财务七规则</span>
<div>
<el-select v-model="govEntity" style="width:130px;margin-right:8px;" @change="runGovernanceCheck">
<el-option label="全部实体" :value="0" />
<el-option label="酣客" :value="1" />
<el-option label="博海" :value="2" />
</el-select>
<el-button type="primary" @click="runGovernanceCheck" :loading="govChecking">重新检查</el-button>
</div>
</div>
</template>
<el-row :gutter="16">
<el-col :span="6">
<div style="text-align:center;padding:20px 0;">
<el-progress type="dashboard" :percentage="govScore" :color="govScoreColor" :width="150">
<template #default>
<div style="font-size:32px;font-weight:700;line-height:1.2;">{{ govScore }}</div>
<div style="font-size:12px;color:#999;">质量评分</div>
</template>
</el-progress>
<div style="font-size:12px;color:#999;margin-top:10px;line-height:1.8;">
检查时间: {{ govCheckedAt || '-' }}<br />
通过 {{ govPassedCount }} / {{ govIssues.length }} 条规则
</div>
</div>
</el-col>
<el-col :span="18">
<el-table :data="govIssues" size="small" border>
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding:10px 18px;background:#fafafa;">
<div v-if="row.detail && row.detail.length" style="font-size:12px;line-height:24px;color:#606266;">
<div v-for="(d, i) in row.detail" :key="i"> {{ d }}</div>
</div>
<div v-else style="font-size:12px;color:#67c23a;"> 未发现异常</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="govRuleStatus(row)" size="small">{{ govRuleStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="规则" width="160">
<template #default="{ row }">
<span style="font-weight:600;">{{ govRuleName(row.rule) }}</span>
<div style="font-size:11px;color:#999;">{{ row.rule }}</div>
</template>
</el-table-column>
<el-table-column label="检查内容" min-width="240">
<template #default="{ row }">{{ govRuleDesc(row.rule) }}</template>
</el-table-column>
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="row.level === 'error' ? 'danger' : 'warning'" size="small">
{{ row.level === 'error' ? '异常' : '警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="异常数" width="90">
<template #default="{ row }">
<span :style="{ color: row.count > 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">{{ row.count }}</span>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
<!-- 质量日志列表 --> <!-- 质量日志列表 -->
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
@@ -199,6 +273,14 @@ const checking = ref(false)
const filterSeverity = ref('') const filterSeverity = ref('')
const filterStatus = ref('') const filterStatus = ref('')
// DAMA 数据治理规则检查状态
const govScore = ref(0)
const govIssues = ref<any[]>([])
const govCheckedAt = ref('')
const govEntity = ref(0)
const govChecking = ref(false)
const govRuleMeta = ref<any>({})
const typeChartOption = computed(() => ({ const typeChartOption = computed(() => ({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
series: [{ series: [{
@@ -228,6 +310,33 @@ const staleDataColor = computed(() => {
return '#f56c6c' return '#f56c6c'
}) })
// ── DAMA治理规则 computed ──
const govPassedCount = computed(() => govIssues.value.filter((i: any) => i.count === 0).length)
const govScoreColor = computed(() => {
if (govScore.value >= 90) return '#67c23a'
if (govScore.value >= 60) return '#e6a23c'
return '#f56c6c'
})
function govRuleStatus(row: any) {
if (row.count > 0) return row.level === 'error' ? 'danger' : 'warning'
return 'success'
}
function govRuleStatusText(row: any) {
if (row.count > 0) return row.level === 'error' ? '异常' : '警告'
return '通过'
}
function govRuleName(rule: string) {
return govRuleMeta.value?.[rule]?.name || rule
}
function govRuleDesc(rule: string) {
return govRuleMeta.value?.[rule]?.desc || ''
}
async function loadStats() { async function loadStats() {
try { const r: any = await dataQualityApi.stats(); stats.value = r } catch (e) {} try { const r: any = await dataQualityApi.stats(); stats.value = r } catch (e) {}
} }
@@ -257,6 +366,24 @@ async function runCheck() {
checking.value = false checking.value = false
} }
async function runGovernanceCheck() {
govChecking.value = true
try {
const params: any = {}
if (govEntity.value) params.entity_id = govEntity.value
const r: any = await dataQualityApi.checkGovernance(params)
govScore.value = r.score ?? 0
govIssues.value = r.issues || []
govCheckedAt.value = (r.checked_at || '').replace('T', ' ').slice(0, 19)
govRuleMeta.value = r.rules_meta || {}
const bad = (r.issues || []).filter((i: any) => i.count > 0).length
ElMessage.success(`治理检查完成: 评分${r.score}分, 异常规则${bad}`)
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '治理检查失败')
}
govChecking.value = false
}
async function updateStatus(id: number, status: string) { async function updateStatus(id: number, status: string) {
try { try {
await dataQualityApi.updateLog(id, { status }) await dataQualityApi.updateLog(id, { status })
@@ -269,6 +396,7 @@ async function updateStatus(id: number, status: string) {
onMounted(() => { onMounted(() => {
loadStats() loadStats()
loadLogs() loadLogs()
runGovernanceCheck()
}) })
</script> </script>