run_wp_sd7_module_readiness.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env python3
  2. """WP-SD7 module readiness inventory.
  3. Reusable. Never auto-passes a module.
  4. Checks L1→L2 (and L3/L4 when configured), operational-factory facts,
  5. KPI day coverage, S8 exception presence, and improvement-loop status.
  6. Live create→approve→execute→verify→close is recorded only when actually run.
  7. """
  8. from __future__ import annotations
  9. import json
  10. import re
  11. from datetime import date, datetime
  12. from decimal import Decimal
  13. from pathlib import Path
  14. import pymysql
  15. ROOT = Path(__file__).resolve().parents[4]
  16. CONFIG = ROOT / "server" / "Admin.NET.Application" / "Configuration" / "Database.json"
  17. EVIDENCE = ROOT / "doc" / "plan" / "UAT留证" / "2026-08-17-智慧诊断正式落地"
  18. MODULES = ("S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9")
  19. TENANTS = {
  20. "A": {"code": "A", "name": "UATTEST_CHL", "tenant_id": 838257186181189},
  21. "B": {"code": "B", "name": "UATTEST_CHLB", "tenant_id": 838257212780613},
  22. "DEMO": {"code": "DEMO", "name": "UATDEMO", "tenant_id": 838257237606469},
  23. }
  24. MODULE_SOURCES = {
  25. "S1": ("order_schedule",),
  26. "S2": ("order_schedule",),
  27. "S3": ("supplier_delivery", "material_readiness"),
  28. "S4": ("supplier_delivery",),
  29. "S5": ("iqc_bill",),
  30. "S6": ("order_schedule", "s6_report"),
  31. "S7": ("fqc_result",),
  32. "S9": (
  33. "order_schedule",
  34. "supplier_delivery",
  35. "material_readiness",
  36. "s6_report",
  37. "iqc_bill",
  38. "fqc_result",
  39. ),
  40. }
  41. SOURCE_SQL = {
  42. "order_schedule": """
  43. SELECT COUNT(*) c FROM dwd_order_schedule_trans
  44. WHERE tenant_id=%s AND IFNULL(work_order,'')<>''
  45. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=%s)
  46. AND (factory_id IS NULL OR factory_id<>%s)
  47. """,
  48. "supplier_delivery": """
  49. SELECT COUNT(*) c FROM dwd_supplier_delivery
  50. WHERE tenant_id=%s AND IFNULL(po_no,'')<>''
  51. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=%s)
  52. AND (factory_id IS NULL OR factory_id<>%s)
  53. """,
  54. "material_readiness": """
  55. SELECT COUNT(*) c FROM dwd_material_readiness
  56. WHERE tenant_id=%s AND IFNULL(shortage_qty,0)>0
  57. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=%s)
  58. AND (factory_id IS NULL OR factory_id<>%s)
  59. """,
  60. "s6_report": """
  61. SELECT COUNT(*) c FROM mdp_std_s6_report
  62. WHERE tenant_id=%s AND IFNULL(work_order_no,'')<>''
  63. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=%s)
  64. AND (factory_id IS NULL OR factory_id<>%s)
  65. """,
  66. "iqc_bill": """
  67. SELECT COUNT(*) c FROM qms_qcp_inspbill
  68. WHERE tenant_id=%s AND IFNULL(FBILLNO,'')<>''
  69. """,
  70. "fqc_result": """
  71. SELECT COUNT(*) c FROM mdp_std_fqc_result
  72. WHERE tenant_id=%s AND IFNULL(bill_no,'')<>''
  73. """,
  74. }
  75. def connect() -> pymysql.Connection:
  76. raw = CONFIG.read_text(encoding="utf-8-sig")
  77. value = next(
  78. item
  79. for item in re.findall(r'(?m)^\s*"ConnectionString"\s*:\s*"([^"]+)"', raw)
  80. if "Database=aidopdev" in item
  81. )
  82. parts = {
  83. item.split("=", 1)[0].strip().lower(): item.split("=", 1)[1].strip()
  84. for item in value.split(";")
  85. if "=" in item
  86. }
  87. return pymysql.connect(
  88. host=parts["server"],
  89. port=int(parts["port"]),
  90. user=parts["uid"],
  91. password=parts["pwd"],
  92. database=parts["database"],
  93. charset="utf8mb4",
  94. cursorclass=pymysql.cursors.DictCursor,
  95. )
  96. def json_safe(value: object) -> object:
  97. if isinstance(value, datetime):
  98. return value.isoformat(timespec="seconds")
  99. if isinstance(value, date):
  100. return value.isoformat()
  101. if isinstance(value, Decimal):
  102. return str(value)
  103. if isinstance(value, dict):
  104. return {str(key): json_safe(item) for key, item in value.items()}
  105. if isinstance(value, (list, tuple)):
  106. return [json_safe(item) for item in value]
  107. if isinstance(value, int) and abs(value) > 2**53:
  108. return str(value)
  109. return value
  110. def scalar(cur: pymysql.cursors.DictCursor, sql: str, params: tuple = ()) -> int:
  111. cur.execute(sql, params)
  112. row = cur.fetchone() or {}
  113. return int(next(iter(row.values())) or 0)
  114. def fetch_all(cur: pymysql.cursors.DictCursor, sql: str, params: tuple = ()) -> list[dict]:
  115. cur.execute(sql, params)
  116. return [json_safe(row) for row in cur.fetchall()]
  117. def tenant_meta(cur: pymysql.cursors.DictCursor, tenant_id: int) -> dict:
  118. cur.execute(
  119. """
  120. SELECT CAST(Id AS CHAR) id, Title title, CAST(OrgId AS CHAR) org_id
  121. FROM SysTenant WHERE Id=%s
  122. """,
  123. (tenant_id,),
  124. )
  125. row = cur.fetchone()
  126. return json_safe(row) if row else {"id": str(tenant_id), "missing": True}
  127. def trees(cur: pymysql.cursors.DictCursor, tenant_id: int, module: str) -> list[dict]:
  128. return fetch_all(
  129. cur,
  130. """
  131. SELECT p.MetricCode l1_code, p.MetricName l1_name,
  132. COUNT(DISTINCT CASE WHEN c2.MetricLevel=2 THEN c2.Id END) l2_count,
  133. COUNT(DISTINCT CASE WHEN c3.MetricLevel=3 THEN c3.Id END) l3_count,
  134. COUNT(DISTINCT CASE WHEN c4.MetricLevel=4 THEN c4.Id END) l4_count
  135. FROM ado_smart_ops_kpi_master p
  136. LEFT JOIN ado_smart_ops_kpi_master c2
  137. ON c2.TenantId=p.TenantId AND c2.ParentId=p.Id AND c2.MetricLevel=2
  138. LEFT JOIN ado_smart_ops_kpi_master c3
  139. ON c3.TenantId=p.TenantId AND c3.ParentId=c2.Id AND c3.MetricLevel=3
  140. LEFT JOIN ado_smart_ops_kpi_master c4
  141. ON c4.TenantId=p.TenantId AND c4.ParentId=c3.Id AND c4.MetricLevel=4
  142. WHERE p.TenantId=%s AND p.ModuleCode=%s AND p.MetricLevel=1 AND IFNULL(p.IsEnabled,1)=1
  143. GROUP BY p.MetricCode, p.MetricName
  144. ORDER BY p.MetricCode
  145. """,
  146. (tenant_id, module),
  147. )
  148. def kpi_days(cur: pymysql.cursors.DictCursor, tenant_id: int, factory_id: int, module: str) -> dict:
  149. result = {}
  150. for level, table in (
  151. ("L1", "ado_s9_kpi_value_l1_day"),
  152. ("L2", "ado_s9_kpi_value_l2_day"),
  153. ("L3", "ado_s9_kpi_value_l3_day"),
  154. ("L4", "ado_s9_kpi_value_l4_day"),
  155. ):
  156. result[level] = fetch_all(
  157. cur,
  158. f"""
  159. SELECT COUNT(*) row_count, COUNT(DISTINCT metric_code) metric_count,
  160. MIN(biz_date) min_date, MAX(biz_date) max_date
  161. FROM {table}
  162. WHERE tenant_id=%s AND module_code=%s AND IFNULL(is_deleted,0)=0
  163. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=%s)
  164. AND (factory_id IS NULL OR factory_id<>%s)
  165. """,
  166. (tenant_id, module, factory_id, tenant_id),
  167. )[0]
  168. return result
  169. def facts(cur: pymysql.cursors.DictCursor, tenant_id: int, factory_id: int, module: str) -> dict:
  170. out = {}
  171. for key in MODULE_SOURCES[module]:
  172. sql = SOURCE_SQL[key]
  173. if key in ("iqc_bill", "fqc_result"):
  174. out[key] = scalar(cur, sql, (tenant_id,))
  175. else:
  176. out[key] = scalar(cur, sql, (tenant_id, factory_id, tenant_id))
  177. return out
  178. def module_report(cur: pymysql.cursors.DictCursor, tenant: dict, factory_id: int, module: str) -> dict:
  179. tree = trees(cur, tenant["tenant_id"], module)
  180. complete = [row for row in tree if int(row["l2_count"] or 0) > 0]
  181. natural_stop = [row["l1_code"] for row in tree if int(row["l2_count"] or 0) == 0]
  182. with_l3 = [row["l1_code"] for row in tree if int(row["l3_count"] or 0) > 0]
  183. with_l4 = [row["l1_code"] for row in tree if int(row["l4_count"] or 0) > 0]
  184. day = kpi_days(cur, tenant["tenant_id"], factory_id, module)
  185. fact = facts(cur, tenant["tenant_id"], factory_id, module)
  186. fact_total = sum(fact.values())
  187. l1_rows = int(day["L1"]["row_count"] or 0)
  188. blockers = []
  189. if not complete:
  190. blockers.append("no_l1_l2_chain")
  191. if fact_total <= 0:
  192. blockers.append("no_registered_facts")
  193. if l1_rows <= 0 and int(day["L2"]["row_count"] or 0) <= 0:
  194. blockers.append("no_kpi_day_values")
  195. plans = fetch_all(
  196. cur,
  197. """
  198. SELECT CAST(Id AS CHAR) id, PlanNo plan_no, Status status, RootCause root_cause
  199. FROM ado_smart_ops_improvement_plan
  200. WHERE TenantId=%s AND ModuleCode=%s
  201. ORDER BY CreateTime DESC
  202. """,
  203. (tenant["tenant_id"], module),
  204. )
  205. closed_loop = any(
  206. row["status"] == "closed" and "[WP-SD7/" in str(row.get("root_cause") or "")
  207. for row in plans
  208. )
  209. if not closed_loop:
  210. blockers.append("improvement_loop_not_closed")
  211. return {
  212. "module": module,
  213. "l1_count": len(tree),
  214. "complete_l1_l2": [row["l1_code"] for row in complete],
  215. "natural_stop_no_l2": natural_stop,
  216. "l3_configured": with_l3,
  217. "l4_configured": with_l4,
  218. "trees": tree,
  219. "kpi_days": day,
  220. "facts": fact,
  221. "fact_total": fact_total,
  222. "plans": plans,
  223. "blockers": blockers,
  224. "verdict": "blocked" if blockers else "ready_for_signoff",
  225. "auto_passed": False,
  226. }
  227. def s8_report(cur: pymysql.cursors.DictCursor, tenant_id: int) -> dict:
  228. count = scalar(
  229. cur,
  230. "SELECT COUNT(*) c FROM ado_s8_exception WHERE tenant_id=%s AND IFNULL(is_deleted,0)=0",
  231. (tenant_id,),
  232. )
  233. linked = scalar(
  234. cur,
  235. """
  236. SELECT COUNT(*) c
  237. FROM ado_smart_ops_improvement_plan p
  238. WHERE p.TenantId=%s AND p.ModuleCode='S8'
  239. """,
  240. (tenant_id,),
  241. )
  242. blockers = []
  243. if count <= 0:
  244. blockers.append("no_s8_exception")
  245. if linked <= 0:
  246. blockers.append("no_s8_linked_plan")
  247. return {
  248. "exception_count": count,
  249. "linked_plan_count": linked,
  250. "blockers": blockers,
  251. "verdict": "blocked" if blockers else "ready_for_signoff",
  252. "note": "S8 does not copy KPI diagnosis tree",
  253. "auto_passed": False,
  254. }
  255. def isolation(cur: pymysql.cursors.DictCursor, tenant_ids: list[int]) -> dict:
  256. leaks = []
  257. for table, column in (
  258. ("dwd_order_schedule_trans", "tenant_id"),
  259. ("dwd_supplier_delivery", "tenant_id"),
  260. ("dwd_material_readiness", "tenant_id"),
  261. ("mdp_std_s6_report", "tenant_id"),
  262. ("qms_qcp_inspbill", "tenant_id"),
  263. ("mdp_std_fqc_result", "tenant_id"),
  264. ):
  265. for tenant_id in tenant_ids:
  266. other = [x for x in tenant_ids if x != tenant_id]
  267. placeholders = ",".join(["%s"] * len(other))
  268. count = scalar(
  269. cur,
  270. f"SELECT COUNT(*) c FROM {table} WHERE {column}=%s AND {column} IN ({placeholders})",
  271. (tenant_id, *other),
  272. )
  273. if count:
  274. leaks.append({"table": table, "tenant_id": str(tenant_id), "count": count})
  275. factory_eq_tenant = {}
  276. for table in (
  277. "dwd_order_schedule_trans",
  278. "dwd_supplier_delivery",
  279. "dwd_material_readiness",
  280. "mdp_std_s6_report",
  281. "ado_s9_kpi_value_l1_day",
  282. ):
  283. factory_eq_tenant[table] = fetch_all(
  284. cur,
  285. f"""
  286. SELECT CAST(tenant_id AS CHAR) tenant_id, COUNT(*) row_count
  287. FROM {table}
  288. WHERE tenant_id IN ({",".join(["%s"] * len(tenant_ids))})
  289. AND factory_id=tenant_id
  290. GROUP BY tenant_id
  291. """,
  292. tuple(tenant_ids),
  293. )
  294. return {"cross_tenant_impossible_leaks": leaks, "factory_eq_tenant": factory_eq_tenant}
  295. def main() -> None:
  296. EVIDENCE.mkdir(parents=True, exist_ok=True)
  297. conn = connect()
  298. try:
  299. cur = conn.cursor()
  300. tenants = []
  301. any_blocked = False
  302. for item in TENANTS.values():
  303. meta = tenant_meta(cur, item["tenant_id"])
  304. factory_id = int(meta.get("org_id") or 0)
  305. modules = [
  306. module_report(cur, item, factory_id, module) for module in MODULES
  307. ]
  308. s8 = s8_report(cur, item["tenant_id"])
  309. if any(row["verdict"] != "ready_for_signoff" for row in modules) or s8["verdict"] != "ready_for_signoff":
  310. any_blocked = True
  311. tenants.append(
  312. {
  313. "tenant": {**item, **meta, "factory_id": str(factory_id)},
  314. "modules": modules,
  315. "s8": s8,
  316. }
  317. )
  318. payload = {
  319. "work_package": "WP-SD7",
  320. "generated_at": datetime.now().isoformat(timespec="seconds"),
  321. "auto_passed": False,
  322. "overall_verdict": "blocked" if any_blocked else "ready_for_signoff",
  323. "must_not_pass": [
  324. "page_open_only",
  325. "kpi_without_facts",
  326. "other_tenant_facts",
  327. "hand_edited_status",
  328. "canned_root_cause",
  329. "s1_only_then_declare_all_done",
  330. ],
  331. "isolation": isolation(cur, [x["tenant_id"] for x in TENANTS.values()]),
  332. "tenants": tenants,
  333. }
  334. out = EVIDENCE / "07-module-readiness.json"
  335. out.write_text(json.dumps(json_safe(payload), ensure_ascii=False, indent=2), encoding="utf-8")
  336. print(out)
  337. print("overall_verdict=", payload["overall_verdict"])
  338. for tenant in tenants:
  339. print(tenant["tenant"]["name"], "S8", tenant["s8"]["verdict"])
  340. for module in tenant["modules"]:
  341. print(
  342. " ",
  343. module["module"],
  344. module["verdict"],
  345. "facts=",
  346. module["fact_total"],
  347. "blockers=",
  348. ",".join(module["blockers"]) or "-",
  349. )
  350. finally:
  351. conn.close()
  352. if __name__ == "__main__":
  353. main()