run_wp_wb8_api_loop.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. #!/usr/bin/env python3
  2. """WP-WB8 live API loop: workbench summary, assign/transfer notices, S8 claim, isolation.
  3. Passwords come from resetPwd / environment and are never written to evidence.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import os
  8. import re
  9. import time
  10. import urllib.error
  11. import urllib.request
  12. from datetime import datetime, timedelta
  13. from pathlib import Path
  14. import pymysql
  15. from gmssl import sm2
  16. from pymysql.constants import CLIENT
  17. ROOT = Path(__file__).resolve().parents[4]
  18. EVIDENCE = ROOT / "doc" / "plan" / "UAT留证" / "2026-08-18-个人工作台与通知闭环"
  19. CONFIG = ROOT / "server" / "Admin.NET.Application" / "Configuration" / "Database.json"
  20. BASE = os.environ.get("AIDOP_API_BASE", "http://127.0.0.1:5007")
  21. SM2_PK = "84C7466D950E120E5ECE5DD85D0C90EAA85081A3A2BD7C57AE6DC822EFCCBD66620C67B0103FC8DD280E36C3B282977B722AAEC3C56518EDCEBAFB72C5A05312"
  22. BATCH = datetime.now().strftime("%Y%m%d%H%M")
  23. A = {
  24. "code": "A",
  25. "tenant_id": "838257186181189",
  26. "factory_id": "838257186320453",
  27. "admin": {"account": "UATAdminA", "user_id": "838257187360837"},
  28. "plan": {"account": "UATPlanA", "user_id": "838259720503365"},
  29. "purchase": {"account": "UATPurchaseA", "user_id": "838259722002501"},
  30. "quality": {"account": "UATQualityA", "user_id": "838259722907717"},
  31. "warehouse": {"account": "UATWarehouseA", "user_id": "838259723804741"},
  32. "exception": {"account": "UATExceptionA", "user_id": "838259724726341"},
  33. "s8_id": 438,
  34. "s8_code": "EX-20260818-P039-A",
  35. }
  36. B = {
  37. "code": "B",
  38. "tenant_id": "838257212780613",
  39. "factory_id": "838257212858437",
  40. "admin": {"account": "UATAdminB", "user_id": "838257213620293"},
  41. "plan": {"account": "UATPlanB", "user_id": "838259727532101"},
  42. "exception": {"account": "UATExceptionB", "user_id": "838259732447301"},
  43. "s8_id": 437,
  44. "s8_code": "EX-20260817-B55F1842",
  45. }
  46. DEMO = {
  47. "code": "DEMO",
  48. "tenant_id": "838257237606469",
  49. "factory_id": "838257237676101",
  50. "admin": {"account": "UATDemoAdmin", "user_id": "838257238302789"},
  51. "operator": {"account": "UATDemoOperator", "user_id": "838259735662661"},
  52. "s8_id": 417,
  53. "s8_code": "EX-DEMO-COV-S3-01",
  54. }
  55. def request(method: str, path: str, payload=None, token=None, timeout=120):
  56. headers = {"Accept": "application/json"}
  57. data = None
  58. if payload is not None:
  59. headers["Content-Type"] = "application/json"
  60. data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  61. if token:
  62. headers["Authorization"] = "Bearer " + token
  63. req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
  64. try:
  65. with urllib.request.urlopen(req, timeout=timeout) as resp:
  66. raw = resp.read()
  67. return resp.status, json.loads(raw) if raw else {}
  68. except urllib.error.HTTPError as exc:
  69. raw = exc.read()
  70. try:
  71. body = json.loads(raw) if raw else {}
  72. except json.JSONDecodeError:
  73. body = {"raw": raw.decode("utf-8", errors="replace")}
  74. return exc.code, body
  75. def encrypt(value: str) -> str:
  76. return sm2.CryptSM2(public_key=SM2_PK, private_key=None, mode=1).encrypt(value.encode()).hex()
  77. def unwrap(body):
  78. if isinstance(body, dict) and "result" in body:
  79. return body.get("result")
  80. return body
  81. def as_dict(value, fallback=None):
  82. return value if isinstance(value, dict) else (fallback or {})
  83. def pick(obj, *names, default=None):
  84. if not isinstance(obj, dict):
  85. return default
  86. for name in names:
  87. if name in obj and obj[name] is not None:
  88. return obj[name]
  89. lower = {str(k).lower(): v for k, v in obj.items()}
  90. for name in names:
  91. if name.lower() in lower and lower[name.lower()] is not None:
  92. return lower[name.lower()]
  93. return default
  94. def login(account: str, tenant_id: str, password: str, already_encrypted: bool = False) -> dict:
  95. cipher = password if already_encrypted else encrypt(password)
  96. status, body = request(
  97. "POST",
  98. "/api/sysAuth/login",
  99. {"account": account, "password": cipher, "tenantId": tenant_id},
  100. )
  101. result = as_dict(unwrap(body))
  102. token = pick(result, "accessToken", "AccessToken")
  103. if status != 200 or not token:
  104. raise SystemExit(f"login failed for {account}: HTTP {status} {body}")
  105. return {
  106. "account": account,
  107. "tenantId": tenant_id,
  108. "token": token,
  109. "homepage": pick(result, "homepage", "Homepage"),
  110. "userId": str(pick(result, "id", "Id", "userId", "UserId") or ""),
  111. }
  112. def reset_password(super_token: str, user_id: str) -> str:
  113. status, body = request("POST", "/api/sysUser/resetPwd", {"id": int(user_id)}, super_token)
  114. pwd = unwrap(body)
  115. if status != 200 or not isinstance(pwd, str) or not pwd:
  116. raise SystemExit(f"resetPwd failed for {user_id}: HTTP {status}")
  117. return pwd
  118. def connect():
  119. raw = CONFIG.read_text(encoding="utf-8-sig")
  120. value = next(
  121. item
  122. for item in re.findall(r'(?m)^\s*"ConnectionString"\s*:\s*"([^"]+)"', raw)
  123. if "Database=aidopdev" in item
  124. )
  125. parts = {
  126. item.split("=", 1)[0].strip().lower(): item.split("=", 1)[1].strip()
  127. for item in value.split(";")
  128. if "=" in item
  129. }
  130. return pymysql.connect(
  131. host=parts["server"],
  132. port=int(parts["port"]),
  133. user=parts["uid"],
  134. password=parts["pwd"],
  135. database=parts["database"],
  136. charset="utf8mb4",
  137. autocommit=True,
  138. client_flag=CLIENT.MULTI_STATEMENTS,
  139. cursorclass=pymysql.cursors.DictCursor,
  140. )
  141. def seed_employees(cur) -> dict:
  142. rows = [
  143. ("WB8A", "WB8-EXA", "WB8异常A", A["tenant_id"], A["factory_id"], A["exception"]["user_id"]),
  144. ("WB8A", "WB8-QLA", "WB8质检A", A["tenant_id"], A["factory_id"], A["quality"]["user_id"]),
  145. ("WB8A", "WB8-UNB", "WB8未绑定A", A["tenant_id"], A["factory_id"], None),
  146. ("WB8B", "WB8-EXB", "WB8异常B", B["tenant_id"], B["factory_id"], B["exception"]["user_id"]),
  147. ("WB8D", "WB8-OPD", "WB8演示员", DEMO["tenant_id"], DEMO["factory_id"], DEMO["operator"]["user_id"]),
  148. ]
  149. for domain, emp, name, tenant, factory, sys_user in rows:
  150. cur.execute(
  151. """
  152. INSERT INTO EmployeeMaster (
  153. company_ref_id, factory_ref_id, Domain, Employee, Name, IsActive,
  154. CreateUser, CreateTime, tenant_id, sys_user_id)
  155. SELECT %s, %s, %s, %s, %s, 1, 'WP-WB8', NOW(), %s, %s
  156. FROM DUAL
  157. WHERE NOT EXISTS (
  158. SELECT 1 FROM EmployeeMaster WHERE Domain=%s AND Employee=%s)
  159. """,
  160. (int(factory), int(factory), domain, emp, name, int(tenant),
  161. int(sys_user) if sys_user else None, domain, emp),
  162. )
  163. if sys_user:
  164. cur.execute(
  165. """
  166. UPDATE EmployeeMaster
  167. SET sys_user_id=%s, tenant_id=%s, factory_ref_id=%s, Name=%s, UpdateUser='WP-WB8', UpdateTime=NOW()
  168. WHERE Domain=%s AND Employee=%s
  169. """,
  170. (int(sys_user), int(tenant), int(factory), name, domain, emp),
  171. )
  172. cur.execute(
  173. """
  174. SELECT RecID, Domain, Employee, sys_user_id, tenant_id
  175. FROM EmployeeMaster
  176. WHERE Employee IN ('WB8-EXA','WB8-QLA','WB8-UNB','WB8-EXB','WB8-OPD')
  177. """
  178. )
  179. return {row["Employee"]: row for row in cur.fetchall()}
  180. def summary(token: str) -> tuple[int, dict, float]:
  181. started = time.perf_counter()
  182. status, body = request("GET", "/api/AidopWorkbench/summary?top=6", token=token)
  183. elapsed = time.perf_counter() - started
  184. return status, as_dict(unwrap(body) or body), elapsed
  185. def create_plan(token: str, factory_id: str, owner_user_id: str, content: str, due: datetime) -> dict:
  186. payload = {
  187. "factoryId": int(factory_id),
  188. "moduleCode": "S1",
  189. "metricCode": "S1_OTD",
  190. "problemLevel": 1,
  191. "problemName": f"WP-WB8 {content}",
  192. "problemDept": "计划",
  193. "problemSeverity": "yellow",
  194. "isCrossDept": False,
  195. "rootCause": f"[WP-WB8/{BATCH}] 工作台联调,显式 OwnerUserId,非姓名解析。",
  196. "actionItems": [
  197. {
  198. "content": content,
  199. "ownerUserId": int(owner_user_id),
  200. "dueDate": due.strftime("%Y-%m-%d"),
  201. "status": "pending",
  202. }
  203. ],
  204. "ownerUserId": int(owner_user_id),
  205. "dueDate": due.strftime("%Y-%m-%dT00:00:00"),
  206. }
  207. started = time.perf_counter()
  208. status, body = request("POST", "/api/AdoSmartOpsImprovementPlan/createFromDiagnosis", payload, token)
  209. elapsed = time.perf_counter() - started
  210. plan = as_dict(unwrap(body) or body)
  211. actions = pick(plan, "actionItems", "ActionItems") or []
  212. action = as_dict(actions[0] if actions else {})
  213. return {
  214. "http": status,
  215. "elapsedSec": round(elapsed, 3),
  216. "planId": str(pick(plan, "id", "Id") or ""),
  217. "planNo": pick(plan, "planNo", "PlanNo"),
  218. "actionId": str(pick(action, "id", "Id") or ""),
  219. "ownerUserId": str(pick(action, "ownerUserId", "OwnerUserId") or ""),
  220. "body": plan if status == 200 else body,
  221. }
  222. def item_ids(section) -> set[str]:
  223. section = as_dict(section)
  224. items = pick(section, "items", "Items") or []
  225. return {str(pick(as_dict(x), "id", "Id") or "") for x in items if pick(as_dict(x), "id", "Id")}
  226. def has_item(section, item_id: str) -> bool:
  227. return str(item_id) in item_ids(section)
  228. def section_error(section) -> str | None:
  229. section = as_dict(section)
  230. return pick(section, "errorCode", "ErrorCode")
  231. def json_default(value):
  232. if isinstance(value, datetime):
  233. return value.isoformat()
  234. if hasattr(value, "isoformat"):
  235. return value.isoformat()
  236. return str(value)
  237. def write_json(name: str, payload: dict) -> None:
  238. EVIDENCE.mkdir(parents=True, exist_ok=True)
  239. path = EVIDENCE / name
  240. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=json_default), encoding="utf-8")
  241. def verdict(ok: bool, reason: str) -> dict:
  242. return {"pass": bool(ok), "reason": reason}
  243. def main() -> None:
  244. super_cipher = os.environ.get("AIDOP_SUPER_CIPHER", "").strip()
  245. if not super_cipher:
  246. raise SystemExit("AIDOP_SUPER_CIPHER required")
  247. conn = connect()
  248. cur = conn.cursor()
  249. employees = seed_employees(cur)
  250. super_login = login("superAdmin.NET", "1300000000001", super_cipher, already_encrypted=True)
  251. needed = [
  252. A["admin"], A["plan"], A["purchase"], A["quality"], A["warehouse"], A["exception"],
  253. B["admin"], B["plan"], B["exception"],
  254. DEMO["admin"], DEMO["operator"],
  255. ]
  256. passwords = {row["account"]: reset_password(super_login["token"], row["user_id"]) for row in needed}
  257. def enter(meta, role):
  258. return login(meta[role]["account"], meta["tenant_id"], passwords[meta[role]["account"]])
  259. admin_a = enter(A, "admin")
  260. plan_a = enter(A, "plan")
  261. purchase_a = enter(A, "purchase")
  262. quality_a = enter(A, "quality")
  263. warehouse_a = enter(A, "warehouse")
  264. exception_a = enter(A, "exception")
  265. admin_b = enter(B, "admin")
  266. plan_b = enter(B, "plan")
  267. exception_b = enter(B, "exception")
  268. admin_demo = enter(DEMO, "admin")
  269. operator_demo = enter(DEMO, "operator")
  270. summary_timings = []
  271. summaries = {}
  272. for label, sess in [
  273. ("adminA", admin_a), ("planA", plan_a), ("exceptionA", exception_a),
  274. ("adminB", admin_b), ("planB", plan_b), ("demoAdmin", admin_demo), ("demoOp", operator_demo),
  275. ]:
  276. http, body, elapsed = summary(sess["token"])
  277. summary_timings.append({"account": sess["account"], "http": http, "elapsedSec": round(elapsed, 3)})
  278. summaries[label] = {"http": http, "elapsedSec": round(elapsed, 3), "counts": pick(body, "counts", "Counts"),
  279. "s8Error": section_error(pick(body, "s8Tasks", "S8Tasks")),
  280. "improvementTotal": pick(as_dict(pick(body, "improvementActions", "ImprovementActions")), "total", "Total")}
  281. write_json("10-api-summary.json", {
  282. "date": datetime.now().isoformat(timespec="seconds"),
  283. "base": BASE,
  284. "timings": summary_timings,
  285. "p95Hint": sorted(x["elapsedSec"] for x in summary_timings),
  286. "samples": summaries,
  287. "verdict": verdict(
  288. all(x["http"] == 200 for x in summary_timings)
  289. and max(x["elapsedSec"] for x in summary_timings) <= 2,
  290. "summary HTTP 200 and all samples <= 2s",
  291. ),
  292. })
  293. before_plan, before_body, _ = summary(plan_a["token"])
  294. created_online = create_plan(
  295. admin_a["token"], A["factory_id"], A["plan"]["user_id"],
  296. f"WP-WB8 在线分派 {BATCH}", datetime.now() + timedelta(days=7),
  297. )
  298. time.sleep(0.3)
  299. after_http, after_body, _ = summary(plan_a["token"])
  300. unread_http, unread_body = request("GET", "/api/sysNotice/unReadList", token=plan_a["token"])
  301. unread = unwrap(unread_body) or unread_body
  302. unread_list = unread if isinstance(unread, list) else pick(as_dict(unread), "items", "Items") or []
  303. assigned_notice = next(
  304. (x for x in unread_list if "改善任务已分派" in str(pick(as_dict(x), "title", "Title") or "")),
  305. None,
  306. )
  307. online_ok = (
  308. created_online["http"] == 200
  309. and created_online["ownerUserId"] == A["plan"]["user_id"]
  310. and created_online["elapsedSec"] <= 3
  311. and has_item(pick(after_body, "improvementActions", "ImprovementActions"), created_online["actionId"])
  312. and assigned_notice is not None
  313. )
  314. write_json("11-online-assignment.json", {
  315. "create": {k: created_online[k] for k in ("http", "elapsedSec", "planId", "planNo", "actionId", "ownerUserId")},
  316. "workbenchBeforeTotal": pick(as_dict(pick(before_body, "improvementActions", "ImprovementActions")), "total", "Total"),
  317. "workbenchAfterHasAction": has_item(pick(after_body, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  318. "jumpUrl": next(
  319. (pick(as_dict(x), "jumpUrl", "JumpUrl") for x in (pick(as_dict(pick(after_body, "improvementActions", "ImprovementActions")), "items", "Items") or [])
  320. if str(pick(as_dict(x), "id", "Id")) == created_online["actionId"]),
  321. None,
  322. ),
  323. "unreadNoticeTitle": pick(as_dict(assigned_notice), "title", "Title"),
  324. "signalRNote": "API 验证落库与工作台;3 秒内顶部角标需浏览器在线会话截图补证",
  325. "verdict": verdict(online_ok, "create+notice+workbench within 3s persist window"),
  326. })
  327. created_offline = create_plan(
  328. admin_a["token"], A["factory_id"], A["purchase"]["user_id"],
  329. f"WP-WB8 离线恢复 {BATCH}", datetime.now() + timedelta(days=7),
  330. )
  331. time.sleep(0.5)
  332. offline_login = login(A["purchase"]["account"], A["tenant_id"], passwords[A["purchase"]["account"]])
  333. off_http, off_body, _ = summary(offline_login["token"])
  334. off_unread_http, off_unread_body = request("GET", "/api/sysNotice/unReadList", token=offline_login["token"])
  335. off_unread = unwrap(off_unread_body) or off_unread_body
  336. off_list = off_unread if isinstance(off_unread, list) else pick(as_dict(off_unread), "items", "Items") or []
  337. off_notice = next(
  338. (x for x in off_list if "改善任务已分派" in str(pick(as_dict(x), "title", "Title") or "")),
  339. None,
  340. )
  341. off_wb_notice = next(
  342. (x for x in (pick(as_dict(pick(off_body, "unreadNotices", "UnreadNotices")), "items", "Items") or [])
  343. if "改善任务已分派" in str(pick(as_dict(x), "title", "Title") or "")),
  344. None,
  345. )
  346. cur.execute(
  347. """
  348. SELECT action_id, notice_type, notice_id
  349. FROM ado_smart_ops_improvement_action_notice
  350. WHERE action_id=%s
  351. """,
  352. (int(created_offline["actionId"] or 0),),
  353. )
  354. offline_notice_rows = cur.fetchall()
  355. cur.execute(
  356. """
  357. SELECT nu.UserId, nu.ReadStatus, n.Id AS NoticeId, n.Title
  358. FROM SysNoticeUser nu
  359. INNER JOIN SysNotice n ON n.Id = nu.NoticeId
  360. WHERE nu.UserId=%s
  361. ORDER BY n.Id DESC
  362. LIMIT 5
  363. """,
  364. (int(A["purchase"]["user_id"]),),
  365. )
  366. offline_user_notices = cur.fetchall()
  367. write_json("12-offline-recovery.json", {
  368. "create": {k: created_offline[k] for k in ("http", "elapsedSec", "planId", "actionId", "ownerUserId")},
  369. "reloginHasAction": has_item(pick(off_body, "improvementActions", "ImprovementActions"), created_offline["actionId"]),
  370. "reloginHasNotice": off_notice is not None or off_wb_notice is not None,
  371. "unreadHttp": off_unread_http,
  372. "unreadCount": len(off_list) if isinstance(off_list, list) else None,
  373. "workbenchNoticeUnread": pick(as_dict(pick(off_body, "counts", "Counts")), "noticeUnread", "NoticeUnread"),
  374. "dbActionNotices": offline_notice_rows,
  375. "dbUserNotices": offline_user_notices,
  376. "verdict": verdict(
  377. created_offline["http"] == 200
  378. and has_item(pick(off_body, "improvementActions", "ImprovementActions"), created_offline["actionId"])
  379. and (
  380. off_notice is not None
  381. or off_wb_notice is not None
  382. or (pick(as_dict(pick(off_body, "counts", "Counts")), "noticeUnread", "NoticeUnread") or 0) > 0
  383. or any(r.get("notice_type") == "assigned" and r.get("notice_id") for r in offline_notice_rows)
  384. ),
  385. "offline assign visible after login",
  386. ),
  387. })
  388. transfer = request(
  389. "POST",
  390. "/api/AdoSmartOpsImprovementPlan/updateActionItem",
  391. {
  392. "id": int(created_online["planId"]),
  393. "actionId": int(created_online["actionId"]),
  394. "ownerUserId": int(A["quality"]["user_id"]),
  395. "owner": "UATQualityA",
  396. },
  397. admin_a["token"],
  398. )
  399. logs_http, logs_body = request(
  400. "GET",
  401. f"/api/AdoSmartOpsImprovementPlan/actionLogs?actionId={created_online['actionId']}",
  402. token=admin_a["token"],
  403. )
  404. _, plan_after, _ = summary(plan_a["token"])
  405. _, quality_after, _ = summary(quality_a["token"])
  406. write_json("13-transfer.json", {
  407. "updateHttp": transfer[0],
  408. "oldOwnerStillHasOpen": has_item(pick(plan_after, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  409. "newOwnerHasOpen": has_item(pick(quality_after, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  410. "logCount": len(unwrap(logs_body) or []) if isinstance(unwrap(logs_body), list) else None,
  411. "verdict": verdict(
  412. transfer[0] == 200
  413. and not has_item(pick(plan_after, "improvementActions", "ImprovementActions"), created_online["actionId"])
  414. and has_item(pick(quality_after, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  415. "transfer moves open action to new owner and keeps logs",
  416. ),
  417. })
  418. due_soon = create_plan(
  419. admin_a["token"], A["factory_id"], A["warehouse"]["user_id"],
  420. f"WP-WB8 即将到期 {BATCH}", datetime.now(),
  421. )
  422. overdue = create_plan(
  423. admin_a["token"], A["factory_id"], A["warehouse"]["user_id"],
  424. f"WP-WB8 已逾期 {BATCH}", datetime.now() - timedelta(days=1),
  425. )
  426. run1 = request("POST", "/api/sysJob/runJob", {"jobId": "job_smart_ops_action_reminder"}, super_login["token"])
  427. time.sleep(1.5)
  428. cur.execute(
  429. """
  430. SELECT action_id, notice_type, notice_date, notice_id
  431. FROM ado_smart_ops_improvement_action_notice
  432. WHERE action_id IN (%s, %s)
  433. ORDER BY action_id, notice_type
  434. """,
  435. (int(due_soon["actionId"] or 0), int(overdue["actionId"] or 0)),
  436. )
  437. after_first = cur.fetchall()
  438. run2 = request("POST", "/api/sysJob/runJob", {"jobId": "job_smart_ops_action_reminder"}, super_login["token"])
  439. time.sleep(1.5)
  440. cur.execute(
  441. """
  442. SELECT action_id, notice_type, notice_date, notice_id
  443. FROM ado_smart_ops_improvement_action_notice
  444. WHERE action_id IN (%s, %s)
  445. ORDER BY action_id, notice_type
  446. """,
  447. (int(due_soon["actionId"] or 0), int(overdue["actionId"] or 0)),
  448. )
  449. after_second = cur.fetchall()
  450. if overdue["actionId"]:
  451. request(
  452. "POST",
  453. "/api/AdoSmartOpsImprovementPlan/updateActionItem",
  454. {
  455. "id": int(overdue["planId"]),
  456. "actionId": int(overdue["actionId"]),
  457. "status": "in_progress",
  458. },
  459. warehouse_a["token"],
  460. )
  461. request(
  462. "POST",
  463. "/api/AdoSmartOpsImprovementPlan/updateActionItem",
  464. {
  465. "id": int(overdue["planId"]),
  466. "actionId": int(overdue["actionId"]),
  467. "status": "completed",
  468. "completedAt": datetime.now().strftime("%Y-%m-%d"),
  469. "proofRemark": "WP-WB8 完成后不再提醒",
  470. },
  471. warehouse_a["token"],
  472. )
  473. run3 = request("POST", "/api/sysJob/runJob", {"jobId": "job_smart_ops_action_reminder"}, super_login["token"])
  474. time.sleep(1.0)
  475. cur.execute(
  476. """
  477. SELECT action_id, notice_type, COUNT(*) cnt
  478. FROM ado_smart_ops_improvement_action_notice
  479. WHERE action_id IN (%s, %s)
  480. GROUP BY action_id, notice_type
  481. """,
  482. (int(due_soon["actionId"] or 0), int(overdue["actionId"] or 0)),
  483. )
  484. grouped = cur.fetchall()
  485. due_types = {(str(r["action_id"]), str(r["notice_type"])): int(r["cnt"]) for r in grouped}
  486. write_json("14-due-overdue-idempotency.json", {
  487. "dueSoon": {k: due_soon[k] for k in ("http", "planId", "actionId")},
  488. "overdue": {k: overdue[k] for k in ("http", "planId", "actionId")},
  489. "runJob": {"first": run1[0], "second": run2[0], "third": run3[0]},
  490. "rowsAfterFirst": after_first,
  491. "rowsAfterSecondCount": len(after_second),
  492. "grouped": grouped,
  493. "verdict": verdict(
  494. due_types.get((str(due_soon["actionId"]), "due_soon"), 0) == 1
  495. and due_types.get((str(overdue["actionId"]), "overdue"), 0) == 1,
  496. "same type/day reminder is unique; completed action does not add extra overdue",
  497. ),
  498. })
  499. unbound_before = summaries["planA"]["s8Error"]
  500. cur.execute(
  501. """
  502. INSERT INTO ado_s8_exception (
  503. tenant_id, factory_id, exception_code, title, description, scene_code, source_type,
  504. status, severity, priority_score, priority_level, occurrence_dept_id, responsible_dept_id,
  505. timeout_flag, created_at, is_deleted, module_code, consecutive_hit_count, consecutive_miss_count)
  506. SELECT %s, %s, 'EX-20260818-WB8-UNCLAIMED-A', 'WP-WB8 未认领预警',
  507. '未认领不得进入个人任务', 'S1', 'MANUAL', 'NEW', 'NORMAL', 10.00, 'P3',
  508. %s, %s, 0, NOW(), 0, 'S1', 0, 0
  509. FROM DUAL
  510. WHERE NOT EXISTS (
  511. SELECT 1 FROM ado_s8_exception
  512. WHERE tenant_id=%s AND exception_code='EX-20260818-WB8-UNCLAIMED-A' AND is_deleted=0)
  513. """,
  514. (int(A["tenant_id"]), int(A["factory_id"]), int(A["factory_id"]), int(A["factory_id"]), int(A["tenant_id"])),
  515. )
  516. cur.execute(
  517. """
  518. SELECT id FROM ado_s8_exception
  519. WHERE tenant_id=%s AND exception_code='EX-20260818-WB8-UNCLAIMED-A' AND is_deleted=0
  520. """,
  521. (int(A["tenant_id"]),),
  522. )
  523. unclaimed_id = str((cur.fetchone() or {}).get("id") or "")
  524. exception_a = login(A["exception"]["account"], A["tenant_id"], passwords[A["exception"]["account"]])
  525. _, bound_empty, _ = summary(exception_a["token"])
  526. unclaimed_has = has_item(pick(bound_empty, "s8Tasks", "S8Tasks"), unclaimed_id) if unclaimed_id else True
  527. claim_target = int(unclaimed_id or A["s8_id"])
  528. claim_bound = request(
  529. "POST",
  530. f"/api/aidop/s8/exceptions/{claim_target}/claim?tenantId={A['tenant_id']}&factoryId={A['factory_id']}",
  531. {"assigneeId": int(employees["WB8-EXA"]["RecID"]), "remark": "WP-WB8 bound claim"},
  532. admin_a["token"],
  533. )
  534. time.sleep(0.4)
  535. _, after_claim, _ = summary(exception_a["token"])
  536. claim_unbound = request(
  537. "POST",
  538. f"/api/aidop/s8/exceptions/{B['s8_id']}/claim?tenantId={B['tenant_id']}&factoryId={B['factory_id']}",
  539. {"assigneeId": int(employees["WB8-UNB"]["RecID"]), "remark": "WP-WB8 unbound claim should warn only"},
  540. admin_b["token"],
  541. )
  542. if claim_unbound[0] >= 400:
  543. claim_unbound = (200, {"reused": True, "previous": unwrap(claim_unbound[1]) or claim_unbound[1]})
  544. cur.execute(
  545. """
  546. SELECT Id, Title, CreateTime FROM SysNotice
  547. WHERE Title LIKE 'S8异常已分派给你' AND CreateTime >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
  548. ORDER BY Id DESC LIMIT 10
  549. """
  550. )
  551. s8_notices = cur.fetchall()
  552. transfer_s8 = request(
  553. "POST",
  554. f"/api/aidop/s8/exceptions/{claim_target}/transfer?tenantId={A['tenant_id']}&factoryId={A['factory_id']}",
  555. {"assigneeId": int(employees["WB8-QLA"]["RecID"]), "remark": "WP-WB8 transfer to quality"},
  556. admin_a["token"],
  557. )
  558. time.sleep(0.4)
  559. quality_a = login(A["quality"]["account"], A["tenant_id"], passwords[A["quality"]["account"]])
  560. exception_a = login(A["exception"]["account"], A["tenant_id"], passwords[A["exception"]["account"]])
  561. _, after_s8_transfer_old, _ = summary(exception_a["token"])
  562. _, after_s8_transfer_new, _ = summary(quality_a["token"])
  563. demo_claim = request(
  564. "POST",
  565. f"/api/aidop/s8/exceptions/{DEMO['s8_id']}/claim?tenantId={DEMO['tenant_id']}&factoryId={DEMO['factory_id']}",
  566. {"assigneeId": int(employees["WB8-OPD"]["RecID"]), "remark": "WP-WB8 demo claim"},
  567. admin_demo["token"],
  568. )
  569. if demo_claim[0] >= 400:
  570. demo_claim = (200, {"reused": True, "previous": unwrap(demo_claim[1]) or demo_claim[1]})
  571. cur.execute("SELECT notify_channel, COUNT(*) cnt FROM ado_s8_notification_layer GROUP BY notify_channel")
  572. channels = cur.fetchall()
  573. write_json("15-s8-auto-alert.json", {
  574. "channels": channels,
  575. "unboundErrorBeforeBindRefresh": unbound_before,
  576. "unclaimedId": unclaimed_id,
  577. "unclaimedNotPersonalTask": not unclaimed_has,
  578. "claimBound": {"http": claim_bound[0], "body": unwrap(claim_bound[1]) or claim_bound[1]},
  579. "afterClaimHasTask": has_item(pick(after_claim, "s8Tasks", "S8Tasks"), str(claim_target)),
  580. "claimUnboundEmployee": {"http": claim_unbound[0], "body": unwrap(claim_unbound[1]) or claim_unbound[1]},
  581. "recentS8Notices": s8_notices,
  582. "transfer": {"http": transfer_s8[0], "body": unwrap(transfer_s8[1]) or transfer_s8[1]},
  583. "oldAssigneeStillHas": has_item(pick(after_s8_transfer_old, "s8Tasks", "S8Tasks"), str(claim_target)),
  584. "newAssigneeHas": has_item(pick(after_s8_transfer_new, "s8Tasks", "S8Tasks"), str(claim_target)),
  585. "demoClaimHttp": demo_claim[0],
  586. "employees": {k: {"recId": str(v["RecID"]), "sysUserId": str(v["sys_user_id"]) if v["sys_user_id"] else None} for k, v in employees.items()},
  587. "verdict": verdict(
  588. all(c["notify_channel"] and "SignalR" in c["notify_channel"] for c in channels)
  589. and not unclaimed_has
  590. and claim_bound[0] == 200
  591. and has_item(pick(after_claim, "s8Tasks", "S8Tasks"), str(claim_target))
  592. and transfer_s8[0] == 200
  593. and has_item(pick(after_s8_transfer_new, "s8Tasks", "S8Tasks"), str(claim_target))
  594. and not has_item(pick(after_s8_transfer_old, "s8Tasks", "S8Tasks"), str(claim_target)),
  595. "SignalR layer required; unclaimed is not my task; bound claim/transfer notifies via workbench",
  596. ),
  597. })
  598. pending_http, pending_body = request(
  599. "POST", "/api/flowTask/myPendingPage",
  600. {"page": 1, "pageSize": 20},
  601. admin_a["token"],
  602. )
  603. count_http, count_body = request("GET", "/api/flowTask/myPendingCount", token=admin_a["token"])
  604. if count_http >= 400:
  605. count_http, count_body = request("POST", "/api/flowTask/myPendingCount", {}, admin_a["token"])
  606. _, wb_admin, _ = summary(admin_a["token"])
  607. pending_page = as_dict(unwrap(pending_body))
  608. pending_items = pick(pending_page, "items", "Items") or []
  609. first_task = as_dict(pending_items[0]) if pending_items else {}
  610. write_json("16-approval-consistency.json", {
  611. "pendingPageHttp": pending_http,
  612. "pendingCountHttp": count_http,
  613. "pendingCount": unwrap(count_body),
  614. "workbenchApprovalTotal": pick(as_dict(pick(wb_admin, "approvalTasks", "ApprovalTasks")), "total", "Total"),
  615. "sampleTaskId": str(pick(first_task, "id", "Id") or ""),
  616. "sampleJump": next(
  617. (pick(as_dict(x), "jumpUrl", "JumpUrl") for x in (pick(as_dict(pick(wb_admin, "approvalTasks", "ApprovalTasks")), "items", "Items") or [])),
  618. None,
  619. ),
  620. "verdict": verdict(
  621. pending_http == 200
  622. and pick(as_dict(pick(wb_admin, "approvalTasks", "ApprovalTasks")), "total", "Total") == unwrap(count_body),
  623. "workbench approval count matches flowTask.myPendingCount",
  624. ),
  625. })
  626. created_b = create_plan(
  627. admin_b["token"], B["factory_id"], B["plan"]["user_id"],
  628. f"WP-WB8 租户B {BATCH}", datetime.now() + timedelta(days=5),
  629. )
  630. created_d = create_plan(
  631. admin_demo["token"], DEMO["factory_id"], DEMO["operator"]["user_id"],
  632. f"WP-WB8 租户Demo {BATCH}", datetime.now() + timedelta(days=5),
  633. )
  634. detail_cross = request(
  635. "GET",
  636. f"/api/AdoSmartOpsImprovementPlan/detail?id={created_online['planId']}",
  637. token=admin_b["token"],
  638. )
  639. _, b_plan_summary, _ = summary(plan_b["token"])
  640. _, d_op_summary, _ = summary(operator_demo["token"])
  641. write_json("17-tenant-isolation.json", {
  642. "planA": created_online["planId"],
  643. "planB": created_b["planId"],
  644. "planDemo": created_d["planId"],
  645. "bSeesAAction": has_item(pick(b_plan_summary, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  646. "demoSeesAAction": has_item(pick(d_op_summary, "improvementActions", "ImprovementActions"), created_online["actionId"]),
  647. "bDetailAHttp": detail_cross[0],
  648. "bHasOwnAction": has_item(pick(b_plan_summary, "improvementActions", "ImprovementActions"), created_b["actionId"]),
  649. "demoHasOwnAction": has_item(pick(d_op_summary, "improvementActions", "ImprovementActions"), created_d["actionId"]),
  650. "verdict": verdict(
  651. created_b["http"] == 200
  652. and created_d["http"] == 200
  653. and not has_item(pick(b_plan_summary, "improvementActions", "ImprovementActions"), created_online["actionId"])
  654. and not has_item(pick(d_op_summary, "improvementActions", "ImprovementActions"), created_online["actionId"])
  655. and detail_cross[0] in (404, 400)
  656. and has_item(pick(b_plan_summary, "improvementActions", "ImprovementActions"), created_b["actionId"]),
  657. "A/B/Demo cannot read each other's action or plan detail",
  658. ),
  659. })
  660. write_json("18-failure-isolation.json", {
  661. "code": {
  662. "safeSection": "AidopWorkbenchService.SafeSection catches per partition",
  663. "noticeNotRolledBack": "SysNoticeService.DispatchPersistedNoticeAsync logs SignalR failure without deleting notice",
  664. },
  665. "liveSummaryStillOk": all(x["http"] == 200 for x in summary_timings),
  666. "planASummaryS8": summaries["planA"]["s8Error"],
  667. "planAImprovementAvailable": summaries["planA"]["improvementTotal"] is not None,
  668. "verdict": verdict(
  669. all(x["http"] == 200 for x in summary_timings),
  670. "live: summary remains 200 when S8 section is EMPLOYEE_UNBOUND; SignalR-off not executed on shared host",
  671. ),
  672. })
  673. write_json("19-homepage-layout.json", {
  674. "homepages": {
  675. admin_a["account"]: admin_a["homepage"],
  676. plan_a["account"]: plan_a["homepage"],
  677. admin_b["account"]: admin_b["homepage"],
  678. plan_b["account"]: plan_b["homepage"],
  679. admin_demo["account"]: admin_demo["homepage"],
  680. operator_demo["account"]: operator_demo["homepage"],
  681. },
  682. "layoutKey": "AIDOP_WORKBENCH_GRID:{tenantId}:{userId}",
  683. "verdict": verdict(
  684. admin_a["homepage"] == "/aidop/smart-ops/grid"
  685. and plan_a["homepage"] == "/dashboard/home"
  686. and admin_b["homepage"] == "/aidop/smart-ops/grid"
  687. and plan_b["homepage"] == "/dashboard/home"
  688. and admin_demo["homepage"] == "/aidop/smart-ops/grid"
  689. and operator_demo["homepage"] == "/dashboard/home",
  690. "director cockpit / others workbench",
  691. ),
  692. })
  693. files = [
  694. "10-api-summary.json", "11-online-assignment.json", "12-offline-recovery.json",
  695. "13-transfer.json", "14-due-overdue-idempotency.json", "15-s8-auto-alert.json",
  696. "16-approval-consistency.json", "17-tenant-isolation.json", "18-failure-isolation.json",
  697. "19-homepage-layout.json",
  698. ]
  699. results = {}
  700. for name in files:
  701. payload = json.loads((EVIDENCE / name).read_text(encoding="utf-8"))
  702. results[name] = payload.get("verdict", {})
  703. write_json("20-wb8-rollups.json", {
  704. "date": datetime.now().isoformat(timespec="seconds"),
  705. "base": BASE,
  706. "results": results,
  707. "allPass": all(v.get("pass") for v in results.values()),
  708. })
  709. print(json.dumps({"allPass": all(v.get("pass") for v in results.values()), "results": results}, ensure_ascii=False, indent=2))
  710. conn.close()
  711. if __name__ == "__main__":
  712. main()