run_uat_kpi_gap_audit.py 14 KB

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