fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed
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:
@@ -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
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user