run_uat_kpi_gap_audit.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. #!/usr/bin/env python3
  2. """WP-DATA0/1 baseline: enabled KPI coverage, trends, targets, source facts."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. from collections import defaultdict
  7. from datetime import date, datetime, timedelta
  8. from pathlib import Path
  9. from typing import Any
  10. from apply_sql_file import connect
  11. TENANT_ID = 838257186181189
  12. FACTORY_ID = 1
  13. EVIDENCE = Path(__file__).resolve().parents[2] / "UAT留证" / "2026-08-21-全模块实际界面缺失数据闭环"
  14. PIPELINE_CODES = {
  15. "S1_L1_001", "S1_L1_002", "S1_L1_003", "S1_L1_004",
  16. "S1_L2_001", "S1_L2_002", "S1_L2_003", "S1_L2_004", "S1_L2_005", "S1_L2_006",
  17. "S1_L2_010", "S1_L2_011", "S1_L2_012", "S1_L2_013", "S1_L2_014", "S1_L2_015",
  18. "S1_L3_001", "S1_L3_002", "S1_L3_003", "S1_L3_004", "S1_L3_005",
  19. "S1_L3_101", "S1_L3_102", "S1_L3_103", "S1_L3_104", "S1_L3_105",
  20. "S1_L4_001", "S1_L4_002", "S1_L4_003", "S1_L4_004",
  21. "S1_L4_101", "S1_L4_102", "S1_L4_103", "S1_L4_104",
  22. "S2_L1_001", "S2_L1_002", "S2_L1_003", "S2_L1_004",
  23. "S2_L2_001", "S2_L2_002", "S2_L2_003",
  24. "S2_L3_001", "S2_L3_002", "S2_L3_003", "S2_L3_004", "S2_L3_005", "S2_L3_006",
  25. "S3_L1_001", "S3_L1_002", "S3_L1_003", "S3_L1_004",
  26. "S3_L2_004", "S3_L2_005",
  27. "S3_L3_001", "S3_L3_002", "S3_L3_003", "S3_L3_004", "S3_L3_005", "S3_L3_006",
  28. "S4_L1_001", "S4_L1_002", "S4_L1_003", "S4_L1_004",
  29. "S4_L2_001", "S4_L2_002", "S4_L2_003", "S4_L2_004",
  30. "S4_L3_001", "S4_L3_002", "S4_L3_003", "S4_L3_004",
  31. "S5_L1_001", "S5_L1_002", "S5_L1_003", "S5_L1_004",
  32. "S5_L2_001", "S5_L2_002", "S5_L2_003", "S5_L2_004",
  33. "S6_L1_001", "S6_L1_002", "S6_L1_003",
  34. "S6_L2_001", "S6_L2_002", "S6_L2_003",
  35. "S7_L1_001", "S7_L1_002", "S7_L1_003",
  36. "S7_L2_001", "S7_L2_002",
  37. "S8_L1_001", "S8_L1_002",
  38. "S9_L1_001",
  39. }
  40. SEED_ONLY = {
  41. "S1_L2_004", "S1_L3_201", "S1_L3_202", "S1_L3_203", "S1_L3_204", "S1_L3_205",
  42. "S1_L4_201", "S1_L4_202", "S1_L4_203", "S1_L4_204",
  43. }
  44. SEMANTIC = {
  45. "S9_L1_002", "S9_L1_003", "S9_L1_004", "S9_L1_005",
  46. }
  47. NO_STAFF_DENOM = {
  48. "S3_L2_003", "S3_L2_006",
  49. "S5_L1_003", "S5_L2_006", "S5_L2_010", "S5_L2_014", "S5_L3_001", "S5_L3_003",
  50. "S6_L3_003", "S7_L2_003", "S7_L2_007", "S7_L2_011", "S7_L2_015",
  51. }
  52. NO_MRP_SOURCE = {"S3_L2_001", "S3_L2_002", "S3_L2_003"}
  53. NO_PROCESS_REVIEW = {
  54. "S1_L2_007", "S1_L2_008", "S1_L2_009",
  55. "S1_L3_301", "S1_L3_302", "S1_L3_303", "S1_L3_304", "S1_L3_305",
  56. "S1_L4_301", "S1_L4_302", "S1_L4_303", "S1_L4_304",
  57. }
  58. NO_BOM_EVENT = {
  59. "S1_L3_201", "S1_L3_202", "S1_L3_203", "S1_L3_204", "S1_L3_205",
  60. "S1_L4_201", "S1_L4_202", "S1_L4_203", "S1_L4_204",
  61. }
  62. NO_DELIVERY_REVIEW_EVENT = {
  63. "S1_L3_401", "S1_L3_402", "S1_L3_403", "S1_L3_404", "S1_L3_405",
  64. "S1_L4_401", "S1_L4_402", "S1_L4_403", "S1_L4_404",
  65. }
  66. OEE = {"S6_L3_007"}
  67. def dumps(path: Path, payload: Any) -> None:
  68. path.parent.mkdir(parents=True, exist_ok=True)
  69. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
  70. def classify(code: str, has_value: bool, trend_points: int, monthly: bool) -> str:
  71. if code in SEMANTIC:
  72. return "BLOCKED_SEMANTIC"
  73. if code in OEE:
  74. return "BLOCKED_SOURCE_DATA"
  75. if code in NO_MRP_SOURCE or code in NO_PROCESS_REVIEW or code in NO_DELIVERY_REVIEW_EVENT:
  76. return "DISABLE_CANDIDATE" if code in NO_PROCESS_REVIEW or code in NO_DELIVERY_REVIEW_EVENT else "BLOCKED_SOURCE_DATA"
  77. if code in NO_BOM_EVENT:
  78. return "BLOCKED_SOURCE_DATA"
  79. if code in NO_STAFF_DENOM and not has_value:
  80. return "BLOCKED_SOURCE_DATA"
  81. if code not in PIPELINE_CODES:
  82. return "BLOCKED_PIPELINE"
  83. if not has_value:
  84. return "BLOCKED_SOURCE_DATA"
  85. if monthly:
  86. return "COMPLETE_SUMMARY_ONLY" if trend_points < 7 else "COMPLETE_EXACT"
  87. if trend_points >= 7:
  88. return "COMPLETE_EXACT"
  89. return "COMPLETE_SUMMARY_ONLY" if code.startswith("S8_") else "BLOCKED_SOURCE_DATA"
  90. def main() -> None:
  91. parser = argparse.ArgumentParser()
  92. parser.add_argument("--tenant", type=int, default=TENANT_ID)
  93. parser.add_argument("--factory", type=int, default=FACTORY_ID)
  94. args = parser.parse_args()
  95. tenant_id = args.tenant
  96. factory_id = args.factory
  97. since = date.today() - timedelta(days=14)
  98. conn = connect()
  99. try:
  100. with conn.cursor() as cur:
  101. cur.execute(
  102. """
  103. SELECT m.Id, m.MetricCode, m.ModuleCode, m.MetricLevel, m.ParentId, m.MetricName,
  104. m.Formula, m.Unit, m.Direction, m.IsHomePage, m.StatFrequency, m.IsEnabled
  105. FROM ado_smart_ops_kpi_master m
  106. WHERE m.IsEnabled=1 AND m.TenantId IN (0, %s)
  107. ORDER BY m.ModuleCode, m.MetricLevel, m.MetricCode
  108. """,
  109. (tenant_id,),
  110. )
  111. masters = cur.fetchall()
  112. cur.execute(
  113. """
  114. SELECT metric_code, biz_date, metric_value, target_value, target_source, calc_time, 1 AS lvl
  115. FROM ado_s9_kpi_value_l1_day
  116. WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0
  117. UNION ALL
  118. SELECT metric_code, biz_date, metric_value, target_value, target_source, calc_time, 2
  119. FROM ado_s9_kpi_value_l2_day
  120. WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0
  121. UNION ALL
  122. SELECT metric_code, biz_date, metric_value, target_value, target_source, calc_time, 3
  123. FROM ado_s9_kpi_value_l3_day
  124. WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0
  125. UNION ALL
  126. SELECT metric_code, biz_date, metric_value, target_value, target_source, calc_time, 4
  127. FROM ado_s9_kpi_value_l4_day
  128. WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0
  129. """,
  130. (tenant_id, factory_id) * 4,
  131. )
  132. values = cur.fetchall()
  133. cur.execute(
  134. """
  135. SELECT id, module_code, status, heartbeat_at, error_message, started_at, finished_at
  136. FROM ado_module_dashboard_rebuild_job
  137. WHERE tenant_id=%s
  138. ORDER BY id DESC
  139. LIMIT 20
  140. """,
  141. (tenant_id,),
  142. )
  143. jobs = cur.fetchall()
  144. facts: dict[str, Any] = {}
  145. for sql, key in (
  146. ("SELECT COUNT(*) c FROM ado_contract_review WHERE tenant_id=%s", "contract_review"),
  147. ("SELECT COUNT(*) c FROM ado_contract_review_flow WHERE tenant_id=%s", "contract_review_flow"),
  148. ("SELECT COUNT(*) c FROM ado_product_design WHERE tenant_id=%s", "product_design"),
  149. ("SELECT COUNT(*) c FROM mdp_stg_so WHERE tenant_id=%s AND source_table='ado_contract_review'", "stg_contract_review"),
  150. ("SELECT COUNT(*) c FROM mdp_stg_so WHERE tenant_id=%s AND source_table='ado_contract_review_flow'", "stg_review_flow"),
  151. ("SELECT COUNT(*) c FROM mdp_stg_so WHERE tenant_id=%s AND source_table='ado_product_design'", "stg_product_design"),
  152. ("SELECT COUNT(*) c FROM mdp_std_purchase_request WHERE tenant_id=%s", "purchase_request"),
  153. ("SELECT COUNT(*) c FROM mdp_std_purchase_request WHERE tenant_id=%s AND request_date IS NOT NULL AND send_date IS NOT NULL AND send_date>=request_date", "purchase_request_cycle"),
  154. ("SELECT COUNT(*) c FROM mdp_std_purchase_receipt WHERE tenant_id=%s", "purchase_receipt"),
  155. ("SELECT COUNT(*) c FROM qms_qcp_inspbill WHERE tenant_id=%s", "iqc_bill"),
  156. ("SELECT COUNT(*) c FROM qms_qcp_inspbill WHERE tenant_id=%s AND FINSPESTARTDATE IS NOT NULL AND FINSPEENDDATE IS NOT NULL", "iqc_cycle"),
  157. ("SELECT COUNT(*) c FROM ado_s8_exception WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0", "s8_exception"),
  158. ):
  159. if "%s" in sql and sql.count("%s") == 2:
  160. cur.execute(sql, (tenant_id, factory_id))
  161. else:
  162. cur.execute(sql, (tenant_id,))
  163. facts[key] = int(cur.fetchone()["c"])
  164. cur.execute(
  165. """
  166. SELECT id, exception_code, status, severity, module_code, stage_code, factory_id, created_at, closed_at
  167. FROM ado_s8_exception
  168. WHERE tenant_id=%s AND factory_id=%s AND is_deleted=0
  169. """,
  170. (tenant_id, factory_id),
  171. )
  172. s8_rows = cur.fetchall()
  173. finally:
  174. conn.close()
  175. by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
  176. for row in values:
  177. by_code[row["metric_code"]].append(row)
  178. for rows in by_code.values():
  179. rows.sort(key=lambda x: x["biz_date"] or date.min, reverse=True)
  180. enabled = []
  181. coverage = []
  182. trends = []
  183. targets = []
  184. status_rows = []
  185. counts = defaultdict(int)
  186. monthly_codes = {m["MetricCode"] for m in masters if (m.get("StatFrequency") or "") in ("月", "月度") or "人效" in (m["MetricName"] or "") or "周转" in (m["MetricName"] or "")}
  187. for m in masters:
  188. code = m["MetricCode"]
  189. rows = by_code.get(code, [])
  190. latest = next((r for r in rows if r["metric_value"] is not None), None)
  191. recent = [r for r in rows if r["biz_date"] and r["biz_date"] >= since and r["metric_value"] is not None]
  192. points = len({r["biz_date"] for r in recent})
  193. has_value = latest is not None
  194. monthly = code in monthly_codes or code.endswith("_003") and "人效" in (m["MetricName"] or "")
  195. status = classify(code, has_value, points, monthly)
  196. counts[status] += 1
  197. enabled.append({
  198. "metricCode": code,
  199. "moduleCode": m["ModuleCode"],
  200. "level": m["MetricLevel"],
  201. "name": m["MetricName"],
  202. "home": bool(m["IsHomePage"]),
  203. "formula": m["Formula"],
  204. })
  205. coverage.append({
  206. "metricCode": code,
  207. "hasValue": has_value,
  208. "latestDate": latest["biz_date"] if latest else None,
  209. "latestValue": latest["metric_value"] if latest else None,
  210. "calcTime": latest["calc_time"] if latest else None,
  211. })
  212. trends.append({
  213. "metricCode": code,
  214. "recentPoints": points,
  215. "allPoints": len({r["biz_date"] for r in rows if r["metric_value"] is not None}),
  216. })
  217. targets.append({
  218. "metricCode": code,
  219. "targetValue": latest["target_value"] if latest else None,
  220. "targetSource": latest["target_source"] if latest else None,
  221. })
  222. status_rows.append({
  223. "metricCode": code,
  224. "moduleCode": m["ModuleCode"],
  225. "level": m["MetricLevel"],
  226. "name": m["MetricName"],
  227. "hasValue": has_value,
  228. "latestDate": str(latest["biz_date"]) if latest and latest["biz_date"] else None,
  229. "latestValue": latest["metric_value"] if latest else None,
  230. "recentPoints": points,
  231. "status": status,
  232. "pipeline": code in PIPELINE_CODES,
  233. "seedOnly": code in SEED_ONLY,
  234. })
  235. screenshot = [
  236. {"page": "/dashboard/home", "module": m["ModuleCode"], "metricCode": m["MetricCode"], "title": m["MetricName"], "missingType": "home-card"}
  237. for m in masters if m["IsHomePage"]
  238. ]
  239. screenshot.extend(
  240. {"page": f"/aidop/{m['ModuleCode'].lower()}/kanban", "module": m["ModuleCode"], "metricCode": m["MetricCode"], "title": m["MetricName"], "missingType": "module-drill"}
  241. for m in masters if m["ModuleCode"] in {"S1", "S3", "S5", "S6", "S7", "S8", "S9"}
  242. )
  243. payload_status = {
  244. "generatedAt": datetime.now().isoformat(timespec="seconds"),
  245. "tenantId": tenant_id,
  246. "factoryId": factory_id,
  247. "enabledCount": len(masters),
  248. "statusCounts": dict(counts),
  249. "facts": facts,
  250. "s8Exceptions": s8_rows,
  251. "rebuildJobs": jobs,
  252. "items": status_rows,
  253. }
  254. dumps(EVIDENCE / "00-baseline-enabled-kpi.json", {"tenantId": tenant_id, "factoryId": factory_id, "count": len(enabled), "items": enabled})
  255. dumps(EVIDENCE / "01-baseline-value-coverage.json", {"tenantId": tenant_id, "hasValue": sum(1 for x in coverage if x["hasValue"]), "missing": sum(1 for x in coverage if not x["hasValue"]), "items": coverage})
  256. dumps(EVIDENCE / "02-baseline-trend-coverage.json", {"since": str(since), "items": trends})
  257. dumps(EVIDENCE / "03-baseline-target-coverage.json", {"items": targets})
  258. dumps(EVIDENCE / "04-screenshot-metric-map.json", screenshot)
  259. dumps(EVIDENCE / "05-kpi-status-classification.json", payload_status)
  260. print(json.dumps({
  261. "enabled": len(masters),
  262. "hasValue": sum(1 for x in coverage if x["hasValue"]),
  263. "missing": sum(1 for x in coverage if not x["hasValue"]),
  264. "statusCounts": dict(counts),
  265. "facts": facts,
  266. "s8": s8_rows,
  267. "jobs": [{"id": j["id"], "module": j["module_code"], "status": j["status"]} for j in jobs[:8]],
  268. "evidence": str(EVIDENCE),
  269. }, ensure_ascii=False, default=str, indent=2))
  270. if __name__ == "__main__":
  271. main()