feat(data-quality): 财务七规则 governance-check 端点+前端Tab(最终方案)

This commit is contained in:
Hermes CI Fix
2026-08-30 07:33:20 +08:00
parent 0c79ba32c8
commit 974ec48564
3 changed files with 486 additions and 242 deletions
+197
View File
@@ -516,3 +516,200 @@ def check_governance(
"passed": passed, "passed": passed,
"rules_meta": RULES_META, "rules_meta": RULES_META,
} }
# ============================================================
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
# 与 /check-governance 的区别:
# * 评分规则不同:error 扣 min(15, count*3)warning 扣 min(10, count*1)
# * 规则2/3 用 Python 解析 suggestion JSON(不依赖 MySQL JSON 函数)
# * 规则4 区分 error(test/sync/虚拟) 与 manual(待人工确认 warning)
# * 规则7 按 status='active' 口径(与 budget.py 一致,避免多版本叠加失真)
# 只读幂等:不写库、不创建 KpiDataQualityLog
# ============================================================
def _extract_plan_id(suggestion: str):
"""从 kpi_alerts.suggestion (Text 存 JSON) 解析 plan_id;解析失败返回 None"""
if not suggestion:
return None
try:
data = json.loads(suggestion)
return data.get("plan_id")
except Exception:
return None
def _run_governance_checks(db: Session, entity_id: int = 0):
"""执行财务七规则,返回 issues 列表(含 deducted 扣分)。entity_id=0 表示全部实体。"""
eid = entity_id or 0
ent = " AND cp.entity_id = :eid" if eid else ""
issues = []
# ── 规则1 单位校验:amount > 10000(万元口径可疑)──
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 200"
), {"eid": eid}).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/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))
# 已存在的 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()}
# ── 规则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]))
issues.append({
"rule": "dup_alert", "level": "error",
"count": len(dup_groups),
"detail": [f"plan#{pid} 重复预警×{len(ids)}" for pid, ids in dup_groups[:DETAIL_LIMIT]]
+ (["…等%d个plan" % len(dup_groups)] if len(dup_groups) > DETAIL_LIMIT else []),
})
# ── 规则3 孤儿预警:plan_id 指向不存在的 cash_plans ──
orphan_rows = [(aid, kid, pid) for aid, kid, pid in parsed if pid not in plan_id_set]
issues.append({
"rule": "orphan_check", "level": "error",
"count": len(orphan_rows),
"detail": [f"预警#{aid}(kpi#{kid}) → plan#{pid} 不存在" for aid, kid, pid in orphan_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
})
# ── 规则4 虚拟污染:source 含 test/sync/虚拟 → errorsource='manual' → 待人工确认 warning ──
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 '%sync%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%')"
+ ent + " ORDER BY cp.id LIMIT 200"
), {"eid": eid}).fetchall()
manual_rows = db.execute(text(
"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()
issues.append({
"rule": "virtual_pollution", "level": "error",
"count": len(rows),
"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),
"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 []),
})
# ── 规则5 实体归属:kpi_values.entity_id IS NOT NULL 且 != kpi_definitions.entity_id ──
ent_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:
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()
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 无任何实际值 ──
comp_sql = (
"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)"
)
if eid:
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()
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 勾稽验证:预算(active口径)年度合计 vs 年度目标 差异>20% ──
recon_sql = (
"SELECT 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:
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()
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 []),
})
# ── 评分:error 扣 min(15, count*3)warning 扣 min(10, count*1);规则4 manual 按 warning 附加扣 ──
total_deduct = 0
for item in issues:
ded = 0
if item["count"] > 0:
ded += min(15, item["count"] * 3) if item["level"] == "error" else min(10, item["count"] * 1)
# 规则4 附加:manual 待人工确认(warning 性质)
manual_cnt = item.get("manual_count") or 0
if item["rule"] == "virtual_pollution" and manual_cnt > 0:
ded += min(10, manual_cnt * 1)
item["deducted"] = ded
total_deduct += ded
score = max(0, 100 - total_deduct)
passed = [item["rule"] for item in issues if item["count"] == 0]
return {
"checked_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": eid,
"score": score,
"total_deduct": total_deduct,
"total_rules": len(issues),
"issues": issues,
"passed": passed,
"rules_meta": RULES_META,
}
@router.get("/governance-check")
def governance_check(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
db: Session = Depends(get_db),
):
"""财务七规则检查(DAMA数据治理)— 只读幂等,不写库。
7条规则: unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation
评分: 满分100error 扣 min(15, count*3)warning 扣 min(10, count*1)score=max(0, 100-总扣分)
实体筛选: entity_id 参数(0=全部)。
"""
return _run_governance_checks(db, entity_id or 0)
+2
View File
@@ -346,6 +346,8 @@ export const dataQualityApi = {
deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`), deleteLog: (id: number) => api.delete(`/data-quality/logs/${id}`),
// DAMA数据治理规则检查(财务七规则) // DAMA数据治理规则检查(财务七规则)
checkGovernance: (params?: any) => api.get('/data-quality/check-governance', { params }), checkGovernance: (params?: any) => api.get('/data-quality/check-governance', { params }),
// 财务七规则检查(最终方案:评分+7规则+实体筛选,只读幂等)
governanceCheck: (params?: any) => api.get('/data-quality/governance-check', { params }),
} }
export const biReportApi = { export const biReportApi = {
+287 -242
View File
@@ -2,260 +2,284 @@
<div> <div>
<div class="flex-between section-gap"> <div class="flex-between section-gap">
<span class="page-title">数据质量监控</span> <span class="page-title">数据质量监控</span>
<div>
<el-button type="primary" @click="runCheck" :loading="checking">执行全量检查</el-button>
<el-button @click="loadLogs">刷新</el-button>
</div>
</div> </div>
<!-- 统计卡片 --> <el-tabs v-model="activeTab" class="section-gap" @tab-change="handleTabChange">
<el-row :gutter="16" class="section-gap"> <!-- Tab1: KPI质量监控 -->
<el-col :span="6"> <el-tab-pane label="KPI质量监控" name="kpi">
<el-card shadow="never"> <div class="flex-between" style="margin-bottom:16px;">
<div class="stat-card-inner"> <span style="color:#909399;font-size:13px;">KPI值质量检查缺失/骤变/持平/离群</span>
<div class="stat-value" style="color:#409eff;">{{ stats.total_kpis }}</div>
<div class="stat-label">KPI总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:var(--bsc-learning);">{{ stats.open_logs }}</div>
<div class="stat-label">未解决异常</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#f56c6c;">{{ stats.severity_counts?.critical ?? 0 }}</div>
<div class="stat-label">严重异常</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#67c23a;">{{ stats.type_counts?.missing_data ?? 0 }}</div>
<div class="stat-label">数据缺失</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 数据审计看板 -->
<el-row :gutter="16" class="section-gap">
<el-col :span="8">
<el-card shadow="never">
<template #header>📊 KPI完整度评分</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: completenessColor }">
{{ stats.completeness?.score ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
完整 {{ stats.completeness?.complete ?? 0 }} / 总计 {{ stats.completeness?.total ?? 0 }}
</div>
<div style="font-size:12px;color:#f56c6c;margin-top:8px;">
缺失元数据: {{ stats.completeness?.missing_metadata ?? 0 }} 个KPI
</div>
<el-progress
:percentage="stats.completeness?.score ?? 0"
:stroke-width="12"
:color="completenessColor"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>📉 缺失率统计</template>
<div style="text-align:center;padding:12px 0;">
<div style="font-size:36px;font-weight:700;color:#e6a23c;">
{{ stats.data_missing?.rate ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
无数据值的KPI: {{ stats.data_missing?.count ?? 0 }}
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">
总计 {{ stats.data_missing?.total ?? 0 }} 个KPI
</div>
<el-progress
:percentage="stats.data_missing?.rate ?? 0"
:stroke-width="12"
color="#e6a23c"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header> 超30天未更新预警</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: staleDataColor }">
{{ stats.stale_data?.count ?? 0 }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新
</div>
<el-tag v-if="(stats.stale_data?.count ?? 0) > 0" type="danger" size="small" style="margin-top:8px;">
建议立即检查数据源
</el-tag>
<el-tag v-else type="success" size="small" style="margin-top:8px;">
数据更新正常
</el-tag>
</div>
</el-card>
</el-col>
</el-row>
<!-- 异常类型分布 -->
<el-row :gutter="16" class="section-gap">
<el-col :span="24">
<el-card shadow="never">
<template #header>异常类型分布</template>
<v-chart :option="typeChartOption" autoresize style="height:200px;" />
</el-card>
</el-col>
</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> <div>
<el-select v-model="govEntity" style="width:130px;margin-right:8px;" @change="runGovernanceCheck"> <el-button type="primary" @click="runCheck" :loading="checking">执行全量检查</el-button>
<el-option label="全部实体" :value="0" /> <el-button @click="loadLogs">刷新</el-button>
<el-option label="酣客" :value="1" />
<el-option label="博海" :value="2" />
</el-select>
<el-button type="primary" @click="runGovernanceCheck" :loading="govChecking">重新检查</el-button>
</div> </div>
</div> </div>
</template>
<el-row :gutter="16"> <!-- 统计卡片 -->
<el-col :span="6"> <el-row :gutter="16" class="section-gap">
<div style="text-align:center;padding:20px 0;"> <el-col :span="6">
<el-progress type="dashboard" :percentage="govScore" :color="govScoreColor" :width="150"> <el-card shadow="never">
<template #default> <div class="stat-card-inner">
<div style="font-size:32px;font-weight:700;line-height:1.2;">{{ govScore }}</div> <div class="stat-value" style="color:#409eff;">{{ stats.total_kpis }}</div>
<div style="font-size:12px;color:#999;">质量评分</div> <div class="stat-label">KPI总数</div>
</template> </div>
</el-progress> </el-card>
<div style="font-size:12px;color:#999;margin-top:10px;line-height:1.8;"> </el-col>
检查时间: {{ govCheckedAt || '-' }}<br /> <el-col :span="6">
通过 {{ govPassedCount }} / {{ govIssues.length }} 条规则 <el-card shadow="never">
</div> <div class="stat-card-inner">
</div> <div class="stat-value" style="color:var(--bsc-learning);">{{ stats.open_logs }}</div>
</el-col> <div class="stat-label">未解决异常</div>
<el-col :span="18"> </div>
<el-table :data="govIssues" size="small" border> </el-card>
<el-table-column type="expand"> </el-col>
<template #default="{ row }"> <el-col :span="6">
<div style="padding:10px 18px;background:#fafafa;"> <el-card shadow="never">
<div v-if="row.detail && row.detail.length" style="font-size:12px;line-height:24px;color:#606266;"> <div class="stat-card-inner">
<div v-for="(d, i) in row.detail" :key="i"> {{ d }}</div> <div class="stat-value" style="color:#f56c6c;">{{ stats.severity_counts?.critical ?? 0 }}</div>
</div> <div class="stat-label">严重异常</div>
<div v-else style="font-size:12px;color:#67c23a;"> 未发现异常</div> </div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never">
<div class="stat-card-inner">
<div class="stat-value" style="color:#67c23a;">{{ stats.type_counts?.missing_data ?? 0 }}</div>
<div class="stat-label">数据缺失</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 数据审计看板 -->
<el-row :gutter="16" class="section-gap">
<el-col :span="8">
<el-card shadow="never">
<template #header>📊 KPI完整度评分</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: completenessColor }">
{{ stats.completeness?.score ?? '-' }}%
</div> </div>
</template> <div style="font-size:13px;color:#999;margin-top:4px;">
</el-table-column> 完整 {{ stats.completeness?.complete ?? 0 }} / 总计 {{ stats.completeness?.total ?? 0 }}
<el-table-column label="状态" width="80"> </div>
<div style="font-size:12px;color:#f56c6c;margin-top:8px;">
缺失元数据: {{ stats.completeness?.missing_metadata ?? 0 }} 个KPI
</div>
<el-progress
:percentage="stats.completeness?.score ?? 0"
:stroke-width="12"
:color="completenessColor"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>📉 缺失率统计</template>
<div style="text-align:center;padding:12px 0;">
<div style="font-size:36px;font-weight:700;color:#e6a23c;">
{{ stats.data_missing?.rate ?? '-' }}%
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
无数据值的KPI: {{ stats.data_missing?.count ?? 0 }}
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">
总计 {{ stats.data_missing?.total ?? 0 }} 个KPI
</div>
<el-progress
:percentage="stats.data_missing?.rate ?? 0"
:stroke-width="12"
color="#e6a23c"
style="margin-top:12px;"
/>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header> 超30天未更新预警</template>
<div style="text-align:center;padding:12px 0;">
<div :style="{ fontSize: '36px', fontWeight: 700, color: staleDataColor }">
{{ stats.stale_data?.count ?? 0 }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">
个KPI超过 {{ stats.stale_data?.threshold_days ?? 180 }} 天未更新
</div>
<el-tag v-if="(stats.stale_data?.count ?? 0) > 0" type="danger" size="small" style="margin-top:8px;">
建议立即检查数据源
</el-tag>
<el-tag v-else type="success" size="small" style="margin-top:8px;">
数据更新正常
</el-tag>
</div>
</el-card>
</el-col>
</el-row>
<!-- 异常类型分布 -->
<el-row :gutter="16" class="section-gap">
<el-col :span="24">
<el-card shadow="never">
<template #header>异常类型分布</template>
<v-chart :option="typeChartOption" autoresize style="height:200px;" />
</el-card>
</el-col>
</el-row>
<!-- 质量日志列表 -->
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>质量异常日志</span>
<div>
<el-select v-model="filterSeverity" placeholder="严重程度" clearable size="small" style="width:140px;margin-right:8px;" @change="loadLogs">
<el-option label="严重" value="critical" />
<el-option label="警告" value="warning" />
<el-option label="一般" value="info" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width:120px;" @change="loadLogs">
<el-option label="未处理" value="open" />
<el-option label="已处理" value="resolved" />
<el-option label="已忽略" value="ignored" />
</el-select>
</div>
</div>
</template>
<el-table :data="logs" size="small" border v-loading="loading">
<el-table-column prop="kpi_name" label="KPI" min-width="150" />
<el-table-column prop="kpi_code" label="编码" width="100" />
<el-table-column prop="check_type" label="异常类型" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="govRuleStatus(row)" size="small">{{ govRuleStatusText(row) }}</el-tag> <el-tag :type="row.check_type === 'missing_data' ? 'danger' : row.check_type === 'abnormal_change' ? 'warning' : 'info'" size="small">
</template> {{ row.check_type === 'missing_data' ? '数据缺失' : row.check_type === 'abnormal_change' ? '环比骤变' : row.check_type === 'flat_data' ? '连续持平' : '值异常' }}
</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> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="异常数" width="90"> <el-table-column prop="severity" label="严重度" width="80">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: row.count > 0 ? '#f56c6c' : '#67c23a', fontWeight: 600 }">{{ row.count }}</span> <el-tag :type="row.severity === 'critical' ? 'danger' : row.severity === 'warning' ? 'warning' : 'info'" size="small">{{ row.severity }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" label="详情" min-width="250">
<template #default="{ row }">
<div v-if="row.check_type === 'missing_data'">缺失{{ row.detail?.missing_months }}个月数据最新: {{ row.detail?.latest_period ?? '' }}</div>
<div v-else-if="row.check_type === 'abnormal_change'">变化率 {{ row.detail?.change_pct }}%当前: {{ row.detail?.current_value }}上月: {{ row.detail?.previous_value }}</div>
<div v-else-if="row.check_type === 'flat_data'">连续3期持平: {{ row.detail?.flat_value }}</div>
<div v-else-if="row.check_type === 'value_outlier'">Z-Score: {{ row.detail?.z_score }}均值: {{ row.detail?.mean }}</div>
<div v-else>{{ JSON.stringify(row.detail) }}</div>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议操作" min-width="200" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'open' ? 'danger' : row.status === 'resolved' ? 'success' : 'info'" size="small">
{{ row.status === 'open' ? '未处理' : row.status === 'resolved' ? '已解决' : '已忽略' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="发现时间" width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" type="success" plain @click="updateStatus(row.id, 'resolved')">已解决</el-button>
<el-button v-if="row.status === 'open'" size="small" @click="updateStatus(row.id, 'ignored')">忽略</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-col> <el-empty v-if="!loading && logs.length === 0" description="暂无质量异常日志" />
</el-row> </el-card>
</el-card> </el-tab-pane>
<!-- 质量日志列表 --> <!-- Tab2: 财务七规则 -->
<el-card shadow="never"> <el-tab-pane label="财务七规则" name="gov">
<template #header> <div v-if="govLoaded">
<div style="display:flex;justify-content:space-between;align-items:center;"> <el-card shadow="never" class="section-gap">
<span>质量异常日志</span> <template #header>
<div> <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<el-select v-model="filterSeverity" placeholder="严重程度" clearable size="small" style="width:140px;margin-right:8px;" @change="loadLogs"> <span>🛡 DAMA数据治理规则检查财务七规则</span>
<el-option label="严重" value="critical" /> <div>
<el-option label="警告" value="warning" /> <el-select v-model="govEntity" style="width:130px;margin-right:8px;" @change="runGovernanceCheck">
<el-option label="一般" value="info" /> <el-option label="全部实体" :value="0" />
</el-select> <el-option label="酣客" :value="1" />
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width:120px;" @change="loadLogs"> <el-option label="博海" :value="2" />
<el-option label="未处理" value="open" /> </el-select>
<el-option label="已处理" value="resolved" /> <el-button type="primary" @click="runGovernanceCheck" :loading="govChecking">重新检查</el-button>
<el-option label="已忽略" value="ignored" /> </div>
</el-select> </div>
</div> </template>
<el-row :gutter="16">
<!-- 评分大数字 -->
<el-col :span="6">
<div style="text-align:center;padding:16px 0;border-right:1px solid #f0f0f0;">
<div :style="{ fontSize: '56px', fontWeight: 700, lineHeight: 1.2, color: govScoreColor }">
{{ govScore }}
</div>
<div style="font-size:13px;color:#999;margin-top:4px;">质量评分满分100</div>
<div style="font-size:12px;color:#999;margin-top:12px;line-height:1.8;">
检查时间: {{ govCheckedAt || '-' }}<br />
通过 {{ govPassedCount }} / 7 条规则<br />
共扣 {{ govTotalDeduct }}
</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 v-if="row.manual_detail && row.manual_detail.length" style="font-size:12px;line-height:24px;color:#e6a23c;margin-top:4px;">
<div v-for="(d, i) in row.manual_detail" :key="'m'+i"> {{ d }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="govRuleStatus(row)" size="small">{{ govRuleStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="规则" width="150">
<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: govCountColor(row), fontWeight: 600 }">{{ row.count }}</span>
<span v-if="row.manual_count" style="font-size:11px;color:#e6a23c;margin-left:4px;">(待确认{{ row.manual_count }})</span>
</template>
</el-table-column>
<el-table-column label="扣分" width="70">
<template #default="{ row }">
<span :style="{ color: row.deducted > 0 ? '#f56c6c' : '#909399' }">-{{ row.deducted ?? 0 }}</span>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</div> </div>
</template> </el-tab-pane>
<el-table :data="logs" size="small" border v-loading="loading"> </el-tabs>
<el-table-column prop="kpi_name" label="KPI" min-width="150" />
<el-table-column prop="kpi_code" label="编码" width="100" />
<el-table-column prop="check_type" label="异常类型" width="120">
<template #default="{ row }">
<el-tag :type="row.check_type === 'missing_data' ? 'danger' : row.check_type === 'abnormal_change' ? 'warning' : 'info'" size="small">
{{ row.check_type === 'missing_data' ? '数据缺失' : row.check_type === 'abnormal_change' ? '环比骤变' : row.check_type === 'flat_data' ? '连续持平' : '值异常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="severity" label="严重度" width="80">
<template #default="{ row }">
<el-tag :type="row.severity === 'critical' ? 'danger' : row.severity === 'warning' ? 'warning' : 'info'" size="small">{{ row.severity }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" label="详情" min-width="250">
<template #default="{ row }">
<div v-if="row.check_type === 'missing_data'">缺失{{ row.detail?.missing_months }}个月数据最新: {{ row.detail?.latest_period ?? '' }}</div>
<div v-else-if="row.check_type === 'abnormal_change'">变化率 {{ row.detail?.change_pct }}%当前: {{ row.detail?.current_value }}上月: {{ row.detail?.previous_value }}</div>
<div v-else-if="row.check_type === 'flat_data'">连续3期持平: {{ row.detail?.flat_value }}</div>
<div v-else-if="row.check_type === 'value_outlier'">Z-Score: {{ row.detail?.z_score }}均值: {{ row.detail?.mean }}</div>
<div v-else>{{ JSON.stringify(row.detail) }}</div>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议操作" min-width="200" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'open' ? 'danger' : row.status === 'resolved' ? 'success' : 'info'" size="small">
{{ row.status === 'open' ? '未处理' : row.status === 'resolved' ? '已解决' : '已忽略' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="发现时间" width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" type="success" plain @click="updateStatus(row.id, 'resolved')">已解决</el-button>
<el-button v-if="row.status === 'open'" size="small" @click="updateStatus(row.id, 'ignored')">忽略</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && logs.length === 0" description="暂无质量异常日志" />
</el-card>
</div> </div>
</template> </template>
@@ -266,6 +290,9 @@ import VChart from 'vue-echarts'
import 'echarts' import 'echarts'
import { dataQualityApi } from '../api/index' import { dataQualityApi } from '../api/index'
const activeTab = ref('kpi')
const govLoaded = ref(false)
const stats = ref<any>({}) const stats = ref<any>({})
const logs = ref<any[]>([]) const logs = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
@@ -273,8 +300,9 @@ const checking = ref(false)
const filterSeverity = ref('') const filterSeverity = ref('')
const filterStatus = ref('') const filterStatus = ref('')
// DAMA // DAMA
const govScore = ref(0) const govScore = ref(0)
const govTotalDeduct = ref(0)
const govIssues = ref<any[]>([]) const govIssues = ref<any[]>([])
const govCheckedAt = ref('') const govCheckedAt = ref('')
const govEntity = ref(0) const govEntity = ref(0)
@@ -310,22 +338,30 @@ const staleDataColor = computed(() => {
return '#f56c6c' return '#f56c6c'
}) })
// DAMA computed // DAMAcomputed
const govPassedCount = computed(() => govIssues.value.filter((i: any) => i.count === 0).length) const govPassedCount = computed(() => govIssues.value.filter((i: any) => i.count === 0).length)
const govScoreColor = computed(() => { const govScoreColor = computed(() => {
if (govScore.value >= 90) return '#67c23a' if (govScore.value >= 90) return '#67c23a'
if (govScore.value >= 60) return '#e6a23c' if (govScore.value >= 70) return '#e6a23c'
return '#f56c6c' return '#f56c6c'
}) })
function govCountColor(row: any) {
if (row.count > 0) return '#f56c6c'
if ((row.manual_count ?? 0) > 0) return '#e6a23c'
return '#67c23a'
}
function govRuleStatus(row: any) { function govRuleStatus(row: any) {
if (row.count > 0) return row.level === 'error' ? 'danger' : 'warning' if (row.count > 0) return row.level === 'error' ? 'danger' : 'warning'
if ((row.manual_count ?? 0) > 0) return 'warning' //
return 'success' return 'success'
} }
function govRuleStatusText(row: any) { function govRuleStatusText(row: any) {
if (row.count > 0) return row.level === 'error' ? '异常' : '警告' if (row.count > 0) return row.level === 'error' ? '异常' : '警告'
if ((row.manual_count ?? 0) > 0) return '待确认'
return '通过' return '通过'
} }
@@ -337,6 +373,14 @@ function govRuleDesc(rule: string) {
return govRuleMeta.value?.[rule]?.desc || '' return govRuleMeta.value?.[rule]?.desc || ''
} }
function handleTabChange(name: string | number) {
// Tab2 ""
if (name === 'gov' && !govLoaded.value) {
govLoaded.value = true
runGovernanceCheck()
}
}
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) {}
} }
@@ -371,8 +415,9 @@ async function runGovernanceCheck() {
try { try {
const params: any = {} const params: any = {}
if (govEntity.value) params.entity_id = govEntity.value if (govEntity.value) params.entity_id = govEntity.value
const r: any = await dataQualityApi.checkGovernance(params) const r: any = await dataQualityApi.governanceCheck(params)
govScore.value = r.score ?? 0 govScore.value = r.score ?? 0
govTotalDeduct.value = r.total_deduct ?? 0
govIssues.value = r.issues || [] govIssues.value = r.issues || []
govCheckedAt.value = (r.checked_at || '').replace('T', ' ').slice(0, 19) govCheckedAt.value = (r.checked_at || '').replace('T', ' ').slice(0, 19)
govRuleMeta.value = r.rules_meta || {} govRuleMeta.value = r.rules_meta || {}
@@ -396,7 +441,7 @@ async function updateStatus(id: number, status: string) {
onMounted(() => { onMounted(() => {
loadStats() loadStats()
loadLogs() loadLogs()
runGovernanceCheck() // Tab2 governance-check Tab2
}) })
</script> </script>