diff --git a/backend/app/api/data_quality.py b/backend/app/api/data_quality.py index 92c1b187..d705f060 100644 --- a/backend/app/api/data_quality.py +++ b/backend/app/api/data_quality.py @@ -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, + } + diff --git a/frontend/src/views/DataQuality.vue b/frontend/src/views/DataQuality.vue index 9982d549..28250d54 100644 --- a/frontend/src/views/DataQuality.vue +++ b/frontend/src/views/DataQuality.vue @@ -259,10 +259,22 @@ - + @@ -277,6 +289,74 @@ + + + +
+
+ + {{ govDetail.level === 'error' ? '异常' : '警告' }} + + {{ govDetail.name || govRuleName(govDetailRule) }} + {{ govDetailRule }} + 真实总数 {{ govDetail.total }} 条 + 口径:source='manual'(待人工确认组) + + 另有 manual 待确认 {{ govDetailManualTotal }} 条 + +
+ +
+
口径说明:{{ govDetail.desc || govRuleDesc(govDetailRule) }}
+ + + + + + + + + + + + + +
+ 明细为服务端分页(每页 {{ govDetailPageSize }} 条,共 {{ govDetailPageCount }} 页) +
+ + 导出 CSV(全量 {{ govDetail.total }} 条) + + +
+
+ +
@@ -285,8 +365,9 @@ import { ref, onMounted, computed } from 'vue' import { ElMessage } from 'element-plus' import VChart from 'vue-echarts' import 'echarts' -import { dataQualityApi } from '../api/index' +import api, { dataQualityApi } from '../api' import { sessionEntityName } from '../utils/entity' +import MyDialog from '../components/MyDialog.vue' const activeTab = ref('kpi') const govLoaded = ref(false) @@ -308,6 +389,19 @@ const entityLabel = ref(sessionEntityName()) const govChecking = ref(false) const govRuleMeta = ref({}) +// ── 异常明细下钻(2026-09-23 P1:异常数可点击看全量 + 计数口径修正)── +const govDetailVisible = ref(false) +const govDetailRule = ref('') +const govDetailScope = ref<'error' | 'manual'>('error') // virtual_pollution 专用 +const govDetailManualSwitch = ref(false) +const govDetailManualTotal = ref(0) +const govDetailItems = ref([]) +const govDetail = ref({ rule: '', name: '', level: '', desc: '', total: 0 }) +const govDetailPage = ref(1) +const govDetailPageSize = 50 // 与后端契约一致(默认 50、上限 200) +const govDetailLoading = ref(false) +const govExporting = ref(false) + const typeChartOption = computed(() => ({ tooltip: { trigger: 'item' }, series: [{ @@ -372,6 +466,118 @@ function govRuleDesc(rule: string) { return govRuleMeta.value?.[rule]?.desc || '' } +// ── 异常明细下钻 computed ── +const govDetailTitle = computed(() => `异常明细 · ${govDetail.value.name || govRuleName(govDetailRule.value)}`) +const govDetailPageCount = computed(() => Math.max(1, Math.ceil((govDetail.value.total || 0) / govDetailPageSize))) + +/** 关键字段 dict → 一行可读文本(数组/对象做展开) */ +function govKeyFields(row: any) { + const kf = row?.key_fields || {} + return Object.keys(kf).map((k) => { + let v: any = kf[k] + if (v === null || v === undefined || v === '') v = '-' + else if (Array.isArray(v)) v = v.join('/') + else if (typeof v === 'object') v = JSON.stringify(v) + return `${k}:${v}` + }).join(';') +} + +/** 点「异常数」(或 manual 待确认)→ 打开明细;0 值不可点 */ +function openGovDetail(row: any, scope?: 'manual') { + const manual = scope === 'manual' + if (manual ? !(row.manual_count > 0) : !(row.count > 0)) return + govDetailRule.value = row.rule + govDetailScope.value = manual ? 'manual' : 'error' + govDetailManualSwitch.value = manual + govDetailManualTotal.value = row.manual_count || 0 + govDetailPage.value = 1 + govDetailItems.value = [] + govDetail.value = { + rule: row.rule, name: govRuleName(row.rule), level: row.level, + desc: govRuleDesc(row.rule), total: 0, + } + govDetailVisible.value = true + fetchGovDetail() +} + +/** virtual_pollution:「含 manual 待确认」开关(后端 scope=manual) */ +function onGovDetailScopeChange(val: any) { + govDetailScope.value = val ? 'manual' : 'error' + govDetailPage.value = 1 + govDetailItems.value = [] + fetchGovDetail() +} + +/** 明细拉取(服务端分页;账套由会话 token 绑定,前端不传 entity_id) */ +async function fetchGovDetail(page?: number) { + if (!govDetailRule.value) return + if (typeof page === 'number') govDetailPage.value = page + govDetailLoading.value = true + try { + const params: any = { + rule: govDetailRule.value, + page: govDetailPage.value, + page_size: govDetailPageSize, + } + if (govDetailRule.value === 'virtual_pollution' && govDetailScope.value === 'manual') params.scope = 'manual' + const r: any = await api.get('/data-quality/rule-detail', { params }) + govDetail.value = { rule: r.rule, name: r.name, level: r.level, desc: r.desc, total: r.total } + govDetailItems.value = r.items || [] + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '明细加载失败') + } + govDetailLoading.value = false +} + +function csvCell(v: any) { + const s = v === null || v === undefined ? '' : String(v) + return '"' + s.replace(/"/g, '""') + '"' +} + +/** 导出当前规则全量明细(按 total 分批拉取后合并,前端 Blob 生成,不新增后端写接口) */ +async function exportGovDetailCsv() { + if (!govDetailRule.value) return + govExporting.value = true + try { + const exportPageSize = 200 // 后端 page_size 上限 + const base: any = { rule: govDetailRule.value, page_size: exportPageSize } + if (govDetailRule.value === 'virtual_pollution' && govDetailScope.value === 'manual') base.scope = 'manual' + const first: any = await api.get('/data-quality/rule-detail', { params: { ...base, page: 1 } }) + const total = first.total || 0 + let rows: any[] = first.items || [] + const pages = Math.ceil(total / exportPageSize) + for (let p = 2; p <= pages; p++) { + const r: any = await api.get('/data-quality/rule-detail', { params: { ...base, page: p } }) + rows = rows.concat(r.items || []) + } + if (rows.length !== total) { + ElMessage.warning(`导出条数(${rows.length})与真实总数(${total})不一致,已按实际拉取结果导出`) + } + const header = ['记录ID', '账套', '来源', '关键字段', '摘要', '建议动作'] + const lines = [header.map(csvCell).join(',')] + for (const r of rows) { + lines.push([r.record_id, r.entity_id, r.source, govKeyFields(r), r.summary, r.suggested_action].map(csvCell).join(',')) + } + const scopeTag = govDetailScope.value === 'manual' ? '-manual' : '' + const d = new Date() + const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}` + const filename = `数据质量-${govDetail.value.name || govDetailRule.value}-${govDetailRule.value}${scopeTag}-${stamp}.csv` + const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + ElMessage.success(`已导出 ${rows.length} 条(全量 ${total} 条)`) + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '导出失败') + } + govExporting.value = false +} + function handleTabChange(name: string | number) { // Tab2 懒加载:首次切换到"财务七规则"时才请求 if (name === 'gov' && !govLoaded.value) { @@ -449,4 +655,20 @@ onMounted(() => { .stat-card-inner .stat-value { font-size: 28px; font-weight: 600; } .stat-card-inner .stat-label { font-size: 13px; color: #999; } .section-gap-tabs { margin-top: 16px; } + +/* ── 异常数下钻(2026-09-23)── */ +.gov-count-link { cursor: pointer; font-weight: 600; border-bottom: 1px dashed transparent; } +.gov-count-link:hover { border-bottom-color: currentColor; } +.gov-count-link.is-zero { cursor: not-allowed; } +.gov-count-manual { font-size: 11px; color: #e6a23c; margin-left: 4px; cursor: pointer; } +.gov-count-manual:hover { text-decoration: underline; } +.dq-detail-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.dq-detail-name { font-weight: 600; font-size: 14px; } +.dq-detail-code { font-size: 11px; color: #999; } +.dq-detail-total { font-size: 13px; } +.dq-detail-scope { font-size: 12px; color: #e6a23c; } +.dq-detail-desc { font-size: 12px; color: #606266; margin: 6px 0 12px; line-height: 1.6; } +.dq-kv { font-size: 12px; color: #606266; word-break: break-all; } +.dq-detail-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; } +.dq-detail-hint { font-size: 12px; color: #909399; }