325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""自动数据质量监控 — 任务3
|
|
定期检查KPI值异常、连续持平、数据缺失等
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func, and_
|
|
from typing import Optional
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
import logging
|
|
|
|
from app.database import get_db
|
|
from app.auth_middleware import require_auth, require_role
|
|
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
|
|
from app.api.kpis import kpi_to_dict
|
|
|
|
logger = logging.getLogger("data-quality")
|
|
|
|
router = APIRouter(prefix="/api/cma/data-quality", tags=["数据质量"],
|
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
|
)
|
|
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
|
|
|
|
|
def _log_to_dict(log):
|
|
d = {c.name: getattr(log, c.name) for c in log.__table__.columns}
|
|
if hasattr(log, 'kpi') and log.kpi:
|
|
d["kpi_code"] = log.kpi.kpi_code
|
|
d["kpi_name"] = log.kpi.kpi_name
|
|
return d
|
|
|
|
|
|
# ============================================================
|
|
# 质量检查
|
|
# ============================================================
|
|
|
|
@router.get("/check")
|
|
def run_quality_check(db: Session = Depends(get_db)):
|
|
"""扫描全部KPI,生成数据质量报告"""
|
|
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
|
issues = []
|
|
current_period = datetime.now().strftime("%Y-%m")
|
|
|
|
for kpi in kpis:
|
|
# 获取最近12个月的值
|
|
values = db.query(KPIValue).filter(
|
|
KPIValue.kpi_id == kpi.id,
|
|
KPIValue.actual_value.isnot(None),
|
|
).order_by(KPIValue.period.desc()).limit(12).all()
|
|
|
|
# 1. 检查数据缺失
|
|
if not values:
|
|
issues.append({
|
|
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
|
"check_type": "missing_data",
|
|
"severity": "critical",
|
|
"detail": {"missing_months": 12, "latest_period": None, "total_values": 0},
|
|
"suggestion": "请初始化KPI数据,建议导入至少3个月历史数据",
|
|
})
|
|
continue
|
|
|
|
latest_val = values[0]
|
|
latest_period = latest_val.period
|
|
|
|
# 计算缺失月数
|
|
if latest_period:
|
|
try:
|
|
lp_parts = latest_period.split("-")
|
|
lp_date = datetime(int(lp_parts[0]), int(lp_parts[1]), 1)
|
|
now_date = datetime.now().replace(day=1)
|
|
missing_months = max(0, (now_date.year - lp_date.year) * 12 + (now_date.month - lp_date.month) - 1)
|
|
if missing_months > 1:
|
|
issues.append({
|
|
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
|
"check_type": "missing_data",
|
|
"severity": "warning" if missing_months <= 3 else "critical",
|
|
"detail": {"missing_months": missing_months, "latest_period": latest_period, "total_values": len(values)},
|
|
"suggestion": f"数据缺失{missing_months}个月,建议从ERP系统同步或手动补录",
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. 检查环比骤变(需要至少2个月的值)
|
|
if len(values) >= 2 and latest_val.actual_value:
|
|
prev_val = values[1].actual_value
|
|
if prev_val and prev_val != 0:
|
|
change_pct = abs((latest_val.actual_value - prev_val) / prev_val * 100)
|
|
if change_pct > 50:
|
|
issues.append({
|
|
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
|
"check_type": "abnormal_change",
|
|
"severity": "warning" if change_pct <= 100 else "critical",
|
|
"detail": {
|
|
"change_pct": round(change_pct, 1),
|
|
"current_value": latest_val.actual_value,
|
|
"previous_value": prev_val,
|
|
"current_period": latest_val.period,
|
|
"previous_period": values[1].period,
|
|
},
|
|
"suggestion": f"环比变化{round(change_pct,1)}%,建议核实数据是否录入错误",
|
|
})
|
|
|
|
# 3. 检查连续3期持平
|
|
if len(values) >= 3:
|
|
last_3 = [v.actual_value for v in values[:3] if v.actual_value is not None]
|
|
if len(last_3) >= 3 and len(set(last_3)) == 1:
|
|
issues.append({
|
|
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
|
"check_type": "flat_data",
|
|
"severity": "warning",
|
|
"detail": {"flat_value": last_3[0], "periods": [v.period for v in values[:3]]},
|
|
"suggestion": "连续3期数据完全相同,请确认数据源是否正常更新",
|
|
})
|
|
|
|
# 4. 检查值异常(偏离历史均值超过3倍标准差)
|
|
if len(values) >= 4 and latest_val.actual_value:
|
|
hist_vals = [v.actual_value for v in values[1:] if v.actual_value is not None]
|
|
if len(hist_vals) >= 3:
|
|
mean_val = sum(hist_vals) / len(hist_vals)
|
|
variance = sum((v - mean_val) ** 2 for v in hist_vals) / len(hist_vals)
|
|
stddev = variance ** 0.5 if variance > 0 else mean_val * 0.1
|
|
if stddev > 0 and abs(latest_val.actual_value - mean_val) > 3 * stddev:
|
|
issues.append({
|
|
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
|
"check_type": "value_outlier",
|
|
"severity": "warning",
|
|
"detail": {
|
|
"current_value": latest_val.actual_value,
|
|
"mean": round(mean_val, 2),
|
|
"stddev": round(stddev, 2),
|
|
"z_score": round(abs(latest_val.actual_value - mean_val) / stddev, 2),
|
|
},
|
|
"suggestion": "当前值偏离历史均值超过3倍标准差,建议核实",
|
|
})
|
|
|
|
# 写入质量日志
|
|
created_count = 0
|
|
for issue in issues:
|
|
existing = db.query(KpiDataQualityLog).filter(
|
|
KpiDataQualityLog.kpi_id == issue["kpi_id"],
|
|
KpiDataQualityLog.check_type == issue["check_type"],
|
|
KpiDataQualityLog.status == "open",
|
|
).first()
|
|
if not existing:
|
|
log = KpiDataQualityLog(
|
|
kpi_id=issue["kpi_id"],
|
|
check_type=issue["check_type"],
|
|
severity=issue["severity"],
|
|
detail=issue["detail"],
|
|
suggestion=issue["suggestion"],
|
|
status="open",
|
|
)
|
|
db.add(log)
|
|
created_count += 1
|
|
|
|
db.commit()
|
|
return {
|
|
"total_kpis": len(kpis),
|
|
"issues_found": len(issues),
|
|
"new_logs": created_count,
|
|
"issues": issues,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# 质量日志CRUD
|
|
# ============================================================
|
|
|
|
@router.get("/logs")
|
|
def list_quality_logs(
|
|
kpi_id: Optional[int] = None,
|
|
severity: Optional[str] = None,
|
|
check_type: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""获取数据质量日志"""
|
|
query = db.query(KpiDataQualityLog)
|
|
if kpi_id:
|
|
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
|
|
if severity:
|
|
query = query.filter(KpiDataQualityLog.severity == severity)
|
|
if check_type:
|
|
query = query.filter(KpiDataQualityLog.check_type == check_type)
|
|
if status:
|
|
query = query.filter(KpiDataQualityLog.status == status)
|
|
|
|
logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all()
|
|
result = []
|
|
for log in logs:
|
|
d = _log_to_dict(log)
|
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == log.kpi_id).first()
|
|
if kpi:
|
|
d["kpi_code"] = kpi.kpi_code
|
|
d["kpi_name"] = kpi.kpi_name
|
|
result.append(d)
|
|
return {"data": result, "total": len(result)}
|
|
|
|
|
|
@router.put("/logs/{log_id}")
|
|
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
|
"""更新质量日志(解决/忽略)"""
|
|
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
|
if not log:
|
|
raise HTTPException(404, "日志不存在")
|
|
if "status" in data:
|
|
log.status = data["status"]
|
|
if data["status"] == "resolved":
|
|
log.resolved_at = datetime.now()
|
|
if "suggestion" in data:
|
|
log.suggestion = data["suggestion"]
|
|
db.commit()
|
|
return _log_to_dict(log)
|
|
|
|
|
|
@router.delete("/logs/{log_id}")
|
|
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
|
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
|
if log:
|
|
db.delete(log)
|
|
db.commit()
|
|
return {"message": "已删除"}
|
|
|
|
|
|
# ============================================================
|
|
# 数据质量看板统计
|
|
# ============================================================
|
|
|
|
@router.get("/stats")
|
|
def quality_stats(db: Session = Depends(get_db)):
|
|
"""数据质量统计"""
|
|
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
|
|
total_logs = db.query(KpiDataQualityLog).count()
|
|
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
|
|
|
|
# 按严重程度统计
|
|
severity_counts = {}
|
|
for s in ("info", "warning", "critical"):
|
|
cnt = db.query(KpiDataQualityLog).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(
|
|
KpiDataQualityLog.check_type == t,
|
|
KpiDataQualityLog.status == "open",
|
|
).count()
|
|
if cnt:
|
|
type_counts[t] = cnt
|
|
|
|
# ── 数据审计看板统计 ──
|
|
# KPI完整度评分
|
|
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
|
total = len(all_kpis)
|
|
complete_kpis = 0
|
|
missing_metadata_count = 0
|
|
missing_data_count = 0
|
|
stale_data_count = 0
|
|
|
|
from datetime import datetime, timedelta
|
|
six_months_ago = datetime.now() - timedelta(days=180)
|
|
|
|
for kpi in all_kpis:
|
|
# 元数据完整度检查
|
|
has_meta = all([
|
|
kpi.formula and kpi.formula.strip(),
|
|
kpi.data_source and kpi.data_source.strip(),
|
|
kpi.data_owner and kpi.data_owner.strip(),
|
|
kpi.unit and kpi.unit.strip(),
|
|
kpi.target_value is not None,
|
|
])
|
|
if has_meta:
|
|
complete_kpis += 1
|
|
else:
|
|
missing_metadata_count += 1
|
|
|
|
# 数据缺失检查(是否有实际值)
|
|
val = db.query(KPIValue).filter(
|
|
KPIValue.kpi_id == kpi.id,
|
|
KPIValue.actual_value.isnot(None),
|
|
).first()
|
|
if not val:
|
|
missing_data_count += 1
|
|
|
|
# 超30天未更新预警
|
|
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:
|
|
stale_data_count += 1
|
|
|
|
completeness_score = round(complete_kpis / total * 100, 1) if total > 0 else 0
|
|
missing_rate = round(missing_data_count / total * 100, 1) if total > 0 else 0
|
|
|
|
return {
|
|
"total_kpis": total_kpis,
|
|
"total_logs": total_logs,
|
|
"open_logs": open_logs,
|
|
"severity_counts": severity_counts,
|
|
"type_counts": type_counts,
|
|
# 数据审计看板
|
|
"completeness": {
|
|
"score": completeness_score,
|
|
"complete": complete_kpis,
|
|
"total": total,
|
|
"missing_metadata": missing_metadata_count,
|
|
},
|
|
"data_missing": {
|
|
"count": missing_data_count,
|
|
"rate": missing_rate,
|
|
"total": total,
|
|
},
|
|
"stale_data": {
|
|
"count": stale_data_count,
|
|
"threshold_days": 180,
|
|
},
|
|
}
|