fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed

1) 跨账套混算修复(最高优先):/stats 的 total_logs/open_logs/severity_counts/type_counts
   原先直查 kpi_data_quality_log 无任何账套过滤(全库数),而同页 /logs 已按账套收口,
   导致「卡片数字与下方列表对不上」。现四项与 /logs 共用 _scoped_quality_log_query
   (kpi_id join kpi_definitions,内连接,孤儿日志不计)。零 DDL:不给该表加 entity_id 列。
2) /logs 服务端分页(page/page_size,默认 50、上限 200)+ total 改真实 COUNT(*)
   (原为截断后行数 len(),且 limit=100 静默截断)。
3) 新增只读端点 GET /missing-data-kpis:「无实际值 KPI」清单(服务端分页 + 真实 total,
   口径与 /stats.data_missing 逐字一致),供「无实际值 KPI 占比」卡片下钻。
4) 环图黑块修复:ECharts 走 canvas,不解析 CSS 变量,原 itemStyle.color 传 CSS 变量会回退黑色。
   改合法 HEX 实值(与主题色同值 #F56C6C / #E6A23C)。全仓同类写法扫描:33 处 CSS 变量命中,
   仅此 2 处在 ECharts 上下文(已修);ReportCenter.vue:848-849(经 bscScoreColor() 流向 :style)、
   KPIDetail.vue:244/267(el-timeline-item :color 落 inline style)均为 CSS 上下文,非缺陷,未改。
5) 阈值文案单一来源:新增 STALE_THRESHOLD_DAYS = 180,接口 threshold_days 与之同源,
   后端注释同步修正;阈值本身保持 180 不动(改阈值属口径变更,须另行拍板)。
6) 前端 DataQuality.vue:卡片标题加维度限定词(KPI元数据完整度评分 / 无实际值 KPI 占比 /
   超 N 天未更新预警由接口渲染)、顶部四项加层级标注(记录数 vs 实体数)、三张异常卡 + 无实际值
   KPI 可点击,用 MyDialog 打开明细(0 值置灰不可点,服务端分页 50/页,导出 CSV 全量)、
   质量异常日志列表改服务端分页(显示 total,与卡片同口径)。未新增 el-dialog、未改 api/index.ts。

未动:KPI 检查算法 / 检查类型集合 / 异常判定阈值;未回退「财务七规则」标签页既有下钻
(govDetail* 与 rule-detail 端点)。
This commit is contained in:
Hermes CI Fix
2026-09-24 00:40:11 +08:00
parent 467472311d
commit 72d4d97c83
2 changed files with 448 additions and 41 deletions
+97 -19
View File
@@ -31,6 +31,30 @@ def _log_to_dict(log):
return d
# ── 陈旧数据阈值(单一来源 2026-09-24)──
# 阈值本身维持 180 天不变(改阈值属口径变更,须另行拍板);本常量只解决
# 「同一张卡片标题写30天、副文案写180天」的口径漂移:接口与前端文案一律引用本值。
STALE_THRESHOLD_DAYS = 180
# 质量日志分页契约(默认 50/页、上限 200 —— 与 /rule-detail 同规格)
LOG_PAGE_SIZE_DEFAULT = 50
LOG_PAGE_SIZE_MAX = 200
def _scoped_quality_log_query(db: Session, entity_id: int):
"""质量日志「按会话账套收口」的唯一口径(/logs 与 /stats 共用,防两处漂移)
kpi_data_quality_log 表**无 entity_id 列**(本单禁止加列)⇒ 经 kpi_id
join kpi_definitions,取 kpi_definitions.entity_id 判定归属。
内连接语义:kpi_id 无对应 KPI 行的孤儿日志无法归属账套 ⇒ 一律不计(不猜账套)。
"""
return (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
)
# ============================================================
# 质量检查
# ============================================================
@@ -176,20 +200,22 @@ def list_quality_logs(
severity: Optional[str] = None,
check_type: Optional[str] = None,
status: Optional[str] = None,
page: int = Query(1, ge=1, description="页码,从 1 开始(下钻与服务端分页)"),
page_size: int = Query(LOG_PAGE_SIZE_DEFAULT, ge=1, le=LOG_PAGE_SIZE_MAX,
description="每页条数,默认 50,上限 200"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取数据质量日志(账套隔离 2026-09-21)
"""获取数据质量日志(账套隔离 2026-09-21;服务端分页 + 真实 total 2026-09-24)
kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1)
经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。
join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。
`total` = 同筛选条件下的**真实总数**(COUNT(*),不受分页影响)——卡片数字
(/stats)与列表 total 共用 `_scoped_quality_log_query` 同一口径,故二者必然自洽。
"""
query = (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
)
query = _scoped_quality_log_query(db, entity_id)
if kpi_id:
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
if severity:
@@ -199,7 +225,11 @@ def list_quality_logs(
if status:
query = query.filter(KpiDataQualityLog.status == status)
logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all()
total = query.count()
logs = (
query.order_by(KpiDataQualityLog.created_at.desc(), KpiDataQualityLog.id.desc())
.limit(page_size).offset((page - 1) * page_size).all()
)
result = []
for log in logs:
d = _log_to_dict(log)
@@ -208,7 +238,7 @@ def list_quality_logs(
d["kpi_code"] = kpi.kpi_code
d["kpi_name"] = kpi.kpi_name
result.append(d)
return {"data": result, "total": len(result)}
return {"data": result, "total": total, "page": page, "page_size": page_size}
def _scoped_quality_log(db: Session, log_id: int, entity_id: int):
@@ -258,28 +288,36 @@ def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_RO
@router.get("/stats")
def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""数据质量统计(账套隔离 2026-09-21:KPI 口径按会话账套;质量日志表无 entity_id 列,见回报)"""
"""数据质量统计(账套隔离 2026-09-21;日志四项计数收口 2026-09-24)
口径(与 /logs 共用 `_scoped_quality_log_query`,故卡片数字与列表 total 必然自洽):
* total_logs / open_logs / severity_counts / type_counts:质量日志**本账套**计数
(kpi_data_quality_log 无 entity_id 列 ⇒ 经 kpi_id join kpi_definitions 归属)
* 层级:异常类四项为**记录数**(条),total_kpis 与完整性/缺失率为**实体数**(个)
"""
total_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).count()
total_logs = db.query(KpiDataQualityLog).count()
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
# 账套收口:本账套(内连接;孤儿日志不计)
total_logs = _scoped_quality_log_query(db, entity_id).count()
open_logs = _scoped_quality_log_query(db, entity_id).filter(
KpiDataQualityLog.status == "open").count()
# 按严重程度统计
# 按严重程度统计(本账套)
severity_counts = {}
for s in ("info", "warning", "critical"):
cnt = db.query(KpiDataQualityLog).filter(
cnt = _scoped_quality_log_query(db, entity_id).filter(
KpiDataQualityLog.severity == s,
KpiDataQualityLog.status == "open",
).count()
if cnt:
severity_counts[s] = cnt
# 按检查类型统计
# 按检查类型统计(本账套)
type_counts = {}
for t in ("abnormal_change", "flat_data", "missing_data", "value_outlier"):
cnt = db.query(KpiDataQualityLog).filter(
cnt = _scoped_quality_log_query(db, entity_id).filter(
KpiDataQualityLog.check_type == t,
KpiDataQualityLog.status == "open",
).count()
@@ -299,7 +337,7 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en
stale_data_count = 0
from datetime import datetime, timedelta
six_months_ago = datetime.now() - timedelta(days=180)
stale_threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
for kpi in all_kpis:
# 元数据完整度检查
@@ -323,13 +361,13 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en
if not val:
missing_data_count += 1
# 超30天未更新预警
# 超 STALE_THRESHOLD_DAYS 天未更新预警(阈值单一来源,勿在注释/文案里另写天数)
latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if latest_val and latest_val.calculated_at:
if latest_val.calculated_at < six_months_ago:
if latest_val.calculated_at < stale_threshold:
stale_data_count += 1
completeness_score = round(complete_kpis / total * 100, 1) if total > 0 else 0
@@ -355,11 +393,51 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en
},
"stale_data": {
"count": stale_data_count,
"threshold_days": 180,
"threshold_days": STALE_THRESHOLD_DAYS,
},
}
# 数据缺失(data_missing)口径的 KPI 明细 —— 下钻用(只读,2026-09-24)
# 口径与上面 quality_stats() 的 data_missing 计数逐字一致:
# active KPI 且 kpi_values 中不存在 actual_value 非空的行
MISSING_DATA_CALIBER = ("active KPI 且无任何实际值"
"(kpi_values.actual_value 非空的行为 0)")
@router.get("/missing-data-kpis")
def missing_data_kpis(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(LOG_PAGE_SIZE_DEFAULT, ge=1, le=LOG_PAGE_SIZE_MAX,
description="每页条数,默认 50,上限 200"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""「缺失率统计」卡片下钻(只读):无任何实际值的 active KPI 清单
- 账套由会话 token 解析(`Depends(get_entity_id)`),**不新增账套入参**;
- `total` = 同口径 COUNT(*)(不受分页影响),与 `/stats.data_missing.count` 必然相等;
- 列表字段:KPI 编码 / 名称 / 负责人 / 数据源 / 单位(不含值明细,概览用)。
"""
sql = ("SELECT d.id, d.kpi_code, d.kpi_name, d.data_owner, d.data_source, d.unit "
"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 AND v.actual_value IS NOT NULL)")
total = int(db.execute(text("SELECT COUNT(*) FROM (" + sql + ") t"),
{"eid": entity_id}).scalar() or 0)
rows = db.execute(
text(sql + " ORDER BY d.id LIMIT :lim OFFSET :off"),
{"eid": entity_id, "lim": page_size, "off": (page - 1) * page_size},
).fetchall()
items = [{
"kpi_id": r.id, "kpi_code": r.kpi_code, "kpi_name": r.kpi_name,
"data_owner": r.data_owner, "data_source": r.data_source, "unit": r.unit,
} for r in rows]
return {"caliber": MISSING_DATA_CALIBER, "total": total, "page": page,
"page_size": page_size, "items": items}
# ============================================================
# DAMA 数据治理规则检查(财务七规则)— 2026-08-30 P1
# ============================================================