# -*- coding: utf-8 -*- """customer-demo-ref 40 步冒烟(票 14;骨架照搬 t04 + contact-graph 冒烟) 被测:demo/index.html?api=http://localhost:8080(本 effort fork) 前置:SPRING_PROFILES_ACTIVE=verify CRM_MINIO_AK=admin CRM_MINIO_SK=Itc@123456 java -jar crm-app/target/crm-app-1.0.0-SNAPSHOT.jar 运行:py -X utf8 .scratch/customer-demo-ref/demo/smoke.py 认证:page.route 反代 http://localhost:8080/**(剥 origin/host/authorization + 注 Bearer debug token + 补 CORS) 纪律:只测不修;写夹具 e2c-dr-* 用后正门硬删;偏好/规则写后 API 还原;零 PUT/DELETE。 """ import io, os, re, sys, json, time, urllib.request, urllib.parse, urllib.error sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True) import pymysql from playwright.sync_api import sync_playwright ROOT = r"e:\code\crm-backend-matt\.scratch\customer-demo-ref" DEMO = os.path.join(ROOT, "demo") SHOTS = os.path.join(DEMO, "shots"); os.makedirs(SHOTS, exist_ok=True) URL = 'file:///' + os.path.join(DEMO, 'index.html').replace('\\', '/') + '?api=http://localhost:8080' BASE = "http://localhost:8080" ADMIN = "739564171091247104" # 罗伟健 admin BUDDY = "744842318024015872" # 曾偲青(协同样例) PFX = "e2c-dr" TS = time.strftime("%H%M%S") steps, api_urls, console_errors, http_fails = [], [], [], [] def step(name, ok, note=""): steps.append({"step": name, "ok": bool(ok), "note": str(note)[:400]}) print(("PASS " if ok else "FAIL ") + name + (" | " + str(note)[:300] if note else "")) def shot(pg, name): try: pg.screenshot(path=os.path.join(SHOTS, name), full_page=False) except Exception: pass # ---------------- HTTP 直调(夹具/取证不走浏览器;请求同样记入 api_urls 供步 39 端点矩阵覆盖) ---------------- def post(url, data, token=None, raw=False): api_urls.append("POST " + url.split(BASE)[-1]) req = urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") if token: req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=30) as r: b = r.read().decode() return b if raw else json.loads(b) def get_json(url, token=None, raw=False): if url.startswith(BASE): api_urls.append("GET " + url.split(BASE)[-1]) req = urllib.request.Request(url) if token: req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=30) as r: b = r.read().decode() return b if raw else json.loads(b) def get_token(uid, tries=8): for _ in range(tries): try: d = get_json(f"{BASE}/api/auth/debug/token?userId={uid}").get("data") if d: return d if isinstance(d, str) else d.get("token") except Exception: pass time.sleep(2) raise RuntimeError("debug/token 重试后仍失败(服务未起?)") def db(): return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456', database='crm', charset='utf8mb4', autocommit=True, connect_timeout=10, cursorclass=pymysql.cursors.DictCursor) def dbq(sql, args=None): with db() as conn, conn.cursor() as cur: cur.execute(sql, args); return cur.fetchall() def dbx(sql, args=None): with db() as conn, conn.cursor() as cur: cur.execute(sql, args); return cur.rowcount def dict_one(group, token): try: d = get_json(f"{BASE}/api/dict/item/enabled-list?groupCode={group}", token).get("data") or [] return (d[0] or {}).get("code") if d else None except Exception: return None def mk_customer(name, token, ctype, gov, owner=ADMIN, credit=None): form = {"customerName": name, "customerType": ctype, "provinceCode": "440000", "cityCode": "440100", "districtCode": "440103", "industryCode": gov, "customerStarLevel": 3, "relationStarLevel": 3, "isBizNegotiated": 0, "isChild": 0, "ownerUserId": owner} if credit: form["unifiedCreditCode"] = credit r = post(f"{BASE}/api/customer/create", form, token) d = r.get("data") or {} if isinstance(d, dict) and d.get("needConfirm"): r = post(f"{BASE}/api/customer/create", dict(form, confirmSimilar="true"), token) d = r.get("data") or {} if not (isinstance(d, dict) and d.get("id")): raise RuntimeError(f"夹具客户创建失败 {name}: {str(r)[:160]}") return str(d["id"]) def quick_add(cid, rows, token): form = {"customerId": str(cid)} for i, row in enumerate(rows): for k, v in row.items(): form[f"rows[{i}].{k}"] = str(v) r = post(f"{BASE}/api/customer/contact/quickAdd", form, token) d = r.get("data") or {} if d.get("failedRows"): print(" [warn] quickAdd 失败行:", json.dumps(d["failedRows"], ensure_ascii=False)[:200]) return r def job_code_first(token): """job_title 两级树(/api/dict/item/tree)取首个二级 code(quickAdd/导入模板行都吃字典编码)。""" try: tree = get_json(f"{BASE}/api/dict/item/tree?groupCode=job_title", token).get("data") or [] for g in tree: for c in (g.get("children") or []): if c.get("code"): return c["code"] except Exception: pass return None # ---------------- seed 自举(共享库 e2c- 种子缺失时幂等补建;票 12 实测远程库无 e2c- seed) ---------------- SEEDS = {} def ensure_seeds(tok, ctype, gov, jobc): def find(name): rows = dbq("SELECT id, archive_status FROM customer WHERE customer_name=%s AND deleted=0", (name,)) return (str(rows[0]["id"]), rows[0]["archive_status"]) if rows else (None, None) # 1) e2c-全字段-科技(详情/图谱/日志样例,owner=ADMIN,带信用代码) sid, _ = find("e2c-全字段-科技") if not sid: sid = mk_customer("e2c-全字段-科技", tok, ctype, gov, credit="91440101E2CTEST001") SEEDS["full"] = sid # 2) e2c-dr-恒信达科技有限责任公司(查重相似源,步 9/27;带 e2c-dr 前缀便于 sweep 清扫) hid, _ = find("e2c-dr-恒信达科技有限责任公司") if not hid: hid = mk_customer("e2c-dr-恒信达科技有限责任公司", tok, ctype, gov) SEEDS["hxd"] = hid # 2b) 旧版 seed(无 dr 前缀)改名进 sweep 射程,防跨轮残留 old_hid = dbq("SELECT id FROM customer WHERE customer_name=%s AND deleted=0 AND id<>%s", ("e2c-恒信达科技有限责任公司", int(hid))) for r in old_hid: dbx("UPDATE customer SET customer_name=%s WHERE id=%s", ("e2c-dr-旧seed-%s" % r["id"], r["id"])) # 2c) 组织关系 seed:BUDDY 所在部门 leader 缺失 → initiate 67011(D22 总监推导走 sys_dept.leader_user_id) try: bd = dbq("SELECT dept_id FROM crm_auth_user WHERE id=%s", (int(BUDDY),)) if bd and bd[0]["dept_id"]: dbx("UPDATE sys_dept SET leader_user_id=%s WHERE id=%s AND leader_user_id IS NULL", (int(ADMIN), bd[0]["dept_id"])) except Exception as e: print(" [warn] leader seed 跳过: %s" % e) # 2d) 查重设置归位:出厂 FUZZY(2)(CustomerNameMatchMode javadoc「模糊=出厂默认」); # 五跑步 26 save 硬编码 nameMatchMode=1 曾把共享库写成 EXACT,这里幂等归位 try: dd = get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {} if dd.get("nameMatchMode") != 2: post(f"{BASE}/api/rule/customer/dedup/save", {"masterEnabled": dd.get("masterEnabled") or 1, "nameEnabled": dd.get("nameEnabled") or 1, "nameMatchMode": 2, "similarityThreshold": dd.get("similarityThreshold") or 80, "phoneEnabled": dd.get("phoneEnabled") or 1}, tok) print(" [seed] 查重匹配方式归位 FUZZY(2)", flush=True) except Exception as e: print(" [warn] dedup 归位跳过: %s" % e) # 2e) seed 客户冒烟联系人清扫(上轮步 16 的李冒烟/赵冒烟,保证步 16 幂等;「李普通」为共享 seed 预置不动) dbx("DELETE FROM customer_contact WHERE customer_id=%s AND name IN (%s,%s) AND deleted=0", (int(sid), "李冒烟", "赵冒烟")) # 2f) 旧 seed 联系人职务修复:张关键/王重复(9/10 旧行)job_title_id 为 NULL 且 job_title_name # 不在字典树(「采购总监(已更新)」)→ loadContacts 的 _jobCode 反查失败 → saveListAll # 「职务必填」拦截 batchEdit(六跑步 17/39 实锤)。按 jobc 的 dict_item 归位。 try: drow = dbq("SELECT id, name FROM dict_item WHERE code=%s LIMIT 1", (jobc,)) if drow: for nm in ("张关键", "王重复"): dbx("UPDATE customer_contact SET job_title_id=%s, job_title_name=%s " "WHERE customer_id=%s AND name=%s AND job_title_id IS NULL AND deleted=0", (drow[0]["id"], drow[0]["name"], int(sid), nm)) except Exception as e: print(" [warn] 联系人职务 seed 修复跳过: %s" % e) # 3) e2c-已归档(步 5 只读消费) aid, ast = find("e2c-已归档") if not aid: aid = mk_customer("e2c-已归档", tok, ctype, gov) post(f"{BASE}/api/customer/archive?id={aid}", {}, tok) elif ast != 2: post(f"{BASE}/api/customer/archive?id={aid}", {}, tok) SEEDS["archived"] = aid # 4) 联系人样例:张关键 / 王重复(同号 13812340001,步 16/17);行字段=jobTitleCode(RowInput 契约,jobTitleName 不被绑定) rows = dbq("SELECT name FROM customer_contact WHERE customer_id=%s AND deleted=0", (sid,)) have = {r["name"] for r in rows} todo = [] if "张关键" not in have: todo.append({"name": "张关键", "jobTitleCode": jobc, "phone": "13812340001", "isKeyContact": 1}) if "王重复" not in have: todo.append({"name": "王重复", "jobTitleCode": jobc, "phone": "13812340001"}) if todo: quick_add(sid, todo, tok) return SEEDS # ---------------- sweep(e2c-dr* 正门 DB 硬删,幂等重跑) ---------------- def sweep(): n = 0 rows = dbq("SELECT id FROM customer WHERE customer_name LIKE %s", (PFX + "%",)) for row in rows: cid = str(row["id"]); n += 1 # 商机关联行(步 23 建的商机):先删 opportunity_customer 再删商机主体+尽力清子表 opps = [r["opportunity_id"] for r in dbq( "SELECT opportunity_id FROM opportunity_customer WHERE customer_id=%s", (cid,))] dbx("DELETE FROM opportunity_customer WHERE customer_id=%s", (cid,)) for oid in opps: for t in ("opportunity_dynamic", "opportunity_follow", "opportunity_oplog", "opportunity_member"): try: dbx(f"DELETE FROM {t} WHERE opportunity_id=%s", (oid,)) except Exception: pass dbx("DELETE FROM opportunity WHERE id=%s", (oid,)) for t in ("customer_contact_edge", "customer_contact_graph", "customer_contact_reveal_log", "contact_import_fail", "contact_import_task", "customer_import_fail", "customer_import_task", "customer_contact", "customer_oplog", "customer_focus", "customer_follow", "customer_team_member", "customer_view_log", "customer_transfer_detail"): try: dbx(f"DELETE FROM {t} WHERE customer_id=%s", (cid,)) except Exception: pass dbx("DELETE FROM customer WHERE id=%s", (cid,)) # 交接单(发起人/接收人维度,e2c-dr 期间的 JG 单) try: bills = dbq("SELECT id FROM customer_transfer WHERE create_time > DATE_SUB(NOW(), INTERVAL 2 HOUR)") for b in bills: dbx("DELETE FROM customer_transfer_detail WHERE transfer_id=%s", (b["id"],)) dbx("DELETE FROM customer_transfer WHERE id=%s", (b["id"],)) n += 1 except Exception: pass return n # ---------------- multipart 分流(Chromium 不回传文件内容 → Python 重发完整 multipart) ---------------- _CUP_RE = re.compile(r"/api/customer/import/upload") # 客户导入 _CUP_C_RE = re.compile(r"/api/customer/contact/import/upload") # 联系人导入 _FLD = {"importMode": re.compile(rb'name="importMode"\r?\n\r?\n([A-Z_]+)'), "duplicateStrategy": re.compile(rb'name="duplicateStrategy"\r?\n\r?\n([A-Z_]+)'), "customerId": re.compile(rb'name="customerId"\r?\n\r?\n(\d+)')} def _py_upload(path, xlsx, token, mode, strategy, cid=None): boundary = "----smokeBoundaryDr009876543210" parts = [] kv = [("importMode", mode), ("duplicateStrategy", strategy)] if cid: kv.insert(0, ("customerId", cid)) for k, v in kv: parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) with open(xlsx, "rb") as f: fb = f.read() parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{os.path.basename(xlsx)}"\r\n' f'Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n').encode() + fb + b"\r\n") parts.append(f"--{boundary}--\r\n".encode()) req = urllib.request.Request(BASE + path, data=b"".join(parts), method="POST") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode()) def make_handle(route_token): def handle_api(route): req = route.request cors = {"Access-Control-Allow-Origin": req.headers.get("origin") or "null", "Access-Control-Allow-Credentials": "true"} u = req.url p = u.split("?")[0] ct = (req.headers.get("content-type") or "") # NET 断言载体:URL 带查询串;POST 文本体(urlencoded/JSON,multipart 已分流)附记在 # 之后 tail = ("?" + u.split("?", 1)[1]) if "?" in u else "" if req.method == "POST" and "multipart" not in ct: bt = (req.post_data or "")[:400] if bt: tail += " # " + bt.replace("\n", " ") api_urls.append(req.method + " " + p.split(BASE)[-1] + tail) if req.method == "POST" and (_CUP_RE.search(p) or _CUP_C_RE.search(p)): buf = req.post_data_buffer or b"" mode = (_FLD["importMode"].search(buf) or [b"", b"UPSERT"])[1].decode() strat = (_FLD["duplicateStrategy"].search(buf) or [b"", b"SKIP"])[1].decode() cid = (_FLD["customerId"].search(buf) or [b"", b""])[1].decode() or None path = "/api/customer/import/upload" if _CUP_RE.search(p) else "/api/customer/contact/import/upload" xlsx = XLSX_CUST if _CUP_RE.search(p) else XLSX_CONT try: env = _py_upload(path, xlsx, route_token, mode, strat, cid) route.fulfill(status=200, body=json.dumps(env, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", **cors}) except urllib.error.HTTPError as e: route.fulfill(status=e.code, body=e.read(), headers={"Content-Type": "application/json", **cors}) except Exception as e: route.fulfill(status=502, headers={"Content-Type": "application/json", **cors}, body=json.dumps({"code": -1, "success": False, "message": f"py-upload: {e}"})) return h = {k: v for k, v in req.headers.items() if k.lower() not in ("origin", "authorization", "referer", "host", "content-length")} h["Authorization"] = "Bearer " + route_token try: data = req.post_data_buffer if req.method not in ("GET", "HEAD") else None r2 = urllib.request.Request(req.url, data=data, method=req.method) for k, v in h.items(): r2.add_header(k, v) with urllib.request.urlopen(r2, timeout=60) as resp: body = resp.read(); ct = resp.headers.get("Content-Type") or "application/json"; status = resp.status route.fulfill(status=status, body=body, headers={"Content-Type": ct, **cors}) except urllib.error.HTTPError as e: route.fulfill(status=e.code, body=e.read(), headers={ "Content-Type": e.headers.get("Content-Type") or "application/json", **cors}) except Exception as e: route.fulfill(status=502, headers={"Content-Type": "application/json", **cors}, body=json.dumps({"code": -1, "success": False, "message": f"bridge: {e}"})) return handle_api def wait_api(pg, substr, timeout_ms=8000): """轮询 api_urls 直到出现含 substr 的请求(或超时)。""" t0 = time.time() while time.time() - t0 < timeout_ms / 1000: if any(substr in u for u in api_urls): return True pg.wait_for_timeout(200) return False # ================= Part 2 · UI 主流程(步 1-19 A 核心域) ================= def ev(pg, expr): return pg.evaluate("() => " + expr) def read_toast(pg): try: return ev(pg, "[...document.querySelectorAll('#toast .toast-item')].map(x => x.textContent).join(' || ')") or "" except Exception: return "" def dlg_btn(pg, text): """点击主 底栏指定文案按钮。""" return pg.evaluate("(t) => { const b = [...document.querySelectorAll('#dlgFoot button')].find(x => x.textContent === t); if (b) { b.click(); return true; } return false; }", text) def get_bytes(url, token=None): req = urllib.request.Request(url) if token: req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=60) as r: return r.read() def build_xlsx(token, ctype, gov, jobc): """步 20/22 夹具:下载两个导入模板 → openpyxl 动态列头填行。客户=1 完整行+1 缺省份行;联系人=2 行。""" global XLSX_CUST, XLSX_CONT import openpyxl d = os.path.join(DEMO, "tmp_xlsx"); os.makedirs(d, exist_ok=True) tpl = get_bytes(f"{BASE}/api/customer/import/template", token) src = os.path.join(d, "tpl_cust.xlsx"); open(src, "wb").write(tpl) wb = openpyxl.load_workbook(src) ws = wb[wb.sheetnames[0]] heads = {} for c in range(1, (ws.max_column or 1) + 1): v = ws.cell(row=1, column=c).value if v: heads[str(v).strip()] = c def col(*kws): for h, c in heads.items(): if any(k in h for k in kws): return c return None c_name, c_type, c_prov = col("客户名称", "名称"), col("客户类型", "类型"), col("省") for r, nm, prov in ((2, "e2c-dr-imp-全字段", "440000"), (3, "e2c-dr-imp-缺省份", None)): if c_name: ws.cell(row=r, column=c_name, value=nm) if c_type: ws.cell(row=r, column=c_type, value=ctype) if c_prov and prov: ws.cell(row=r, column=c_prov, value=prov) if r == 2: # 完整行尽量补齐城市/行业/星级 for h, c in heads.items(): if ("市" in h or "城市" in h) and "省" not in h: ws.cell(row=r, column=c, value="440100") elif "行业" in h: ws.cell(row=r, column=c, value=gov) elif "星级" in h and "客户" in h and "关系" not in h: ws.cell(row=r, column=c, value=3) XLSX_CUST = os.path.join(d, "cust.xlsx"); wb.save(XLSX_CUST) tpl2 = get_bytes(f"{BASE}/api/customer/contact/import/template", token) wb2 = openpyxl.load_workbook(io.BytesIO(tpl2)) ws2 = wb2[wb2.sheetnames[0]] ws2.append(["李冒烟", jobc, "13812341122", "other", "否"]) ws2.append(["赵冒烟", jobc, "13812341133", "other", "否"]) XLSX_CONT = os.path.join(d, "cont.xlsx"); wb2.save(XLSX_CONT) print(" xlsx 就绪: %s / %s" % (XLSX_CUST, XLSX_CONT), flush=True) def main(): t0 = time.time() tok = get_token(ADMIN) ctype = dict_one("customer_type", tok) or "customer_type_01" gov = dict_one("industry", tok) or "I" jobc = job_code_first(tok) n = sweep() # sweep 必须先于 ensure_seeds:否则上轮 e2c-dr* seed 被 find 命中 → 不重建 → 随即被本 sweep 清掉(五跑步9/27 查重基线缺失根因) print("sweep 清理残留 %s 行" % n, flush=True) print("seed 自举(jobc=%s)..." % jobc, flush=True) ensure_seeds(tok, ctype, gov, jobc) chk = (get_json(f"{BASE}/api/customer/check-name?name={urllib.parse.quote('e2c-dr-恒信达科技有限责任公司')}", tok).get("data") or []) print(" [seed] 查重基线命中数=%s" % len(chk), flush=True) build_xlsx(tok, ctype, gov, jobc) with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context(viewport={"width": 1720, "height": 980}) pg = ctx.new_page() pg.on("pageerror", lambda e: console_errors.append("pageerror: " + str(e)[:300])) pg.on("console", lambda m: console_errors.append("console.error: " + m.text[:300]) if m.type == "error" else None) def on_response(r): try: if r.status >= 400: http_fails.append("%s %s" % (r.status, r.url)) except Exception: pass pg.on("response", on_response) def on_dialog(d): try: d.accept("e2c-dr-视图") if d.type == "prompt" else d.accept() except Exception: pass pg.on("dialog", on_dialog) pg.route("http://localhost:8080/**", make_handle(tok)) pg.goto(URL) pg.wait_for_timeout(1200) # ---- 步 1 登录 + 首屏列表 ---- pg.evaluate("() => { document.getElementById('uidInput').value = '%s'; loginByUid(); }" % ADMIN) pg.wait_for_timeout(2500) nrows = ev(pg, "document.querySelectorAll('#listBody tr').length") or 0 ok1 = nrows > 0 and wait_api(pg, "workspace/page") step("01 打开Demo+uid登录+首屏列表渲染", ok1, "rows=%s" % nrows) shot(pg, "s01-list.png") # ---- 步 2 三 workspace 切换;pool 不发 board + I-06 注记〔D〕 ---- n0 = len(api_urls) pg.evaluate("() => switchWs('overview')"); pg.wait_for_timeout(1500) ov_ok = any("workspace/page" in u for u in api_urls[n0:]) n0 = len(api_urls) pg.evaluate("() => switchWs('pool')"); pg.wait_for_timeout(1500) reqp = api_urls[n0:] i06 = ev(pg, "document.getElementById('i06Note').textContent") or "" i06_vis = ev(pg, "document.getElementById('i06Note').style.display !== 'none'") step("02 三workspace切换;pool不发board+I-06注记[D]", ov_ok and not any(("board/summary" in u or "board/cards" in u) for u in reqp) and i06_vis and ("67001" in i06 and "记忆成功" in i06), "overview请求OK=%s;pool新请求%d条;i06可见=%s" % (ov_ok, len(reqp), i06_vis)) shot(pg, "s02-pool-i06.png") # ---- 步 3 内置视图五值 + FOLLOW_UP_DUE ---- pg.evaluate("() => switchWs('mine')"); pg.wait_for_timeout(1200) opts = ev(pg, "[...document.getElementById('fViewType').options].map(o => o.value)") or [] pg.evaluate("() => { const s = document.getElementById('fViewType'); s.value = 'FOLLOW_UP_DUE'; s.onchange(); }") wait_api(pg, "viewType=FOLLOW_UP_DUE") step("03 内置视图下拉五值+切FOLLOW_UP_DUE", len(opts) == 5 and "RECENT" in opts, "opts=%s" % opts) # ---- 步 4 阶段筛选 customerStage=3 ---- pg.evaluate("() => { const s = document.getElementById('fViewType'); s.value = 'ASSIGNED'; s.onchange(); }") pg.wait_for_timeout(900) pg.evaluate("() => { const s = document.getElementById('fStage'); s.value = '3'; s.onchange(); }") ok4 = wait_api(pg, "customerStage=3") pg.wait_for_timeout(600) step("04 列筛选阶段=已成交", ok4, "请求参数 customerStage=3 已命中" if ok4 else "未见 customerStage=3") # ---- 步 5 归档态开关 ---- pg.evaluate("() => { const s = document.getElementById('fStage'); s.value = ''; s.onchange(); }") pg.wait_for_timeout(900) pg.evaluate("() => { const s = document.getElementById('fArchive'); s.value = '2'; s.onchange(); }") ok5 = wait_api(pg, "archiveStatus=2") # 归档客户可能被分页挤出首页 → keyword 收窄到样例再断言 pg.evaluate("() => { document.getElementById('fKeyword').value = 'e2c-已归档'; S.page = 1; loadList(); }") pg.wait_for_timeout(1200) body5 = ev(pg, "document.getElementById('listBody').textContent") or "" step("05 归档态开关+e2c-已归档可见", ok5 and "e2c-已归档" in body5, "archiveStatus=2命中=%s;样例可见=%s" % (ok5, "e2c-已归档" in body5)) # ---- 步 6 saved-view 全生命周期(JSON body 唯一例外 + prompt 取名) ---- pg.evaluate("() => { const s = document.getElementById('fArchive'); s.value = '1'; s.onchange(); }") pg.wait_for_timeout(900) pg.evaluate("() => { document.getElementById('fKeyword').value = 'e2c'; }") n0 = len(api_urls) pg.evaluate("() => svNew()") wait_api(pg, "view/save"); pg.wait_for_timeout(1000) sv_opt = ev(pg, "[...document.getElementById('svSel').options].some(o => o.textContent.indexOf('e2c-dr-视图') >= 0)") save_urls = [u for u in api_urls[n0:] if "view/save" in u] pick_ok = del_ok = False if sv_opt: pg.evaluate("() => { const s = document.getElementById('svSel'); s.value = s.options[1].value; svPick(s.value); }") pick_ok = wait_api(pg, "savedViewId=") pg.wait_for_timeout(900) if pick_ok: pg.evaluate("() => svDelete()") del_ok = wait_api(pg, "view/delete"); pg.wait_for_timeout(900) gone = ev(pg, "![...document.getElementById('svSel').options].some(o => o.textContent.indexOf('e2c-dr-视图') >= 0)") step("06 saved-view 新建→选中查询→删除", sv_opt and pick_ok and del_ok and gone and bool(save_urls), "save=%s;选中带savedViewId=%s;删后消失=%s;body=%s" % (sv_opt, pick_ok, gone, (save_urls[0][:150] if save_urls else "无"))) # ---- 步 7 形态偏好 board→还原 list ---- pg.evaluate("() => switchWs('overview')"); pg.wait_for_timeout(1200) pg.evaluate("() => { const s = document.getElementById('fForm'); s.value = 'board'; saveForm('board'); }") wait_api(pg, "view-form/save"); wait_api(pg, "board/summary") pg.wait_for_timeout(1800) bvis = ev(pg, "document.getElementById('boardBox').style.display !== 'none'") bcols = ev(pg, "document.getElementById('boardRow').children.length") or 0 cards_hit = wait_api(pg, "board/cards") pg.evaluate("() => { const s = document.getElementById('fForm'); s.value = 'list'; saveForm('list'); }") pg.wait_for_timeout(1100) restored = ev(pg, "document.getElementById('listTable').style.display !== 'none'") step("07 形态偏好overview切看板→渲染→还原list", bvis and cards_hit and bcols >= 2 and restored, "看板列=%s;cards请求=%s;还原=%s" % (bcols, cards_hit, restored)) shot(pg, "s07-board.png") # ---- 步 8 新建表单工商联想 NO_HIT〔D〕 ---- pg.evaluate("() => switchWs('mine')"); pg.wait_for_timeout(1200) pg.evaluate("() => dlgCreate()"); pg.wait_for_timeout(400) pg.evaluate("() => { document.getElementById('cLk').value = '华兴智造科技'; doLookup(); }") ok8 = wait_api(pg, "company-lookup") pg.wait_for_timeout(800) cout = ev(pg, "document.getElementById('cOut').textContent") or "" stub_n = ev(pg, "document.querySelectorAll('#dlg [data-defect=\"STUB-LOOKUP\"]').length") or 0 step("08 工商联想(companyName)+NO_HIT现象+STUB-LOOKUP注记[D]", ok8 and "未查询到匹配企业" in cout and stub_n >= 1, cout[:90]) # ---- 步 9 名称查重 needConfirm → confirmSimilar 重发 ---- pg.evaluate("() => { document.getElementById('cName').value='e2c-dr-恒信达科技有限责任公司';" "document.getElementById('cType').value='%s'; document.getElementById('cProv').value='440000';" "document.getElementById('cCity').value='440100'; document.getElementById('cDist').value='440103';" "document.getElementById('cInd').value='%s'; document.getElementById('cOwner').value='%s'; }" % (ctype, gov, ADMIN)) n0 = len(api_urls) pg.evaluate("() => doCreate('')") wait_api(pg, "/api/customer/create"); pg.wait_for_timeout(1100) simbox = ev(pg, "(document.querySelector('#cOut .simbox')||{}).textContent || ''") or "" confirm_ok = False if "相似命中" in simbox: pg.evaluate("() => { const b = [...document.querySelectorAll('#cOut button')].find(x => x.textContent.indexOf('confirmSimilar') >= 0); if (b) b.click(); }") confirm_ok = wait_api(pg, "confirmSimilar=true"); pg.wait_for_timeout(1300) toasts9 = read_toast(pg) row = dbq("SELECT id FROM customer WHERE customer_name LIKE %s AND deleted=0 ORDER BY id DESC LIMIT 1", ("e2c-dr-恒信达%",)) cid_a = str(row[0]["id"]) if row else "" step("09 名称查重needConfirm+similarHits→confirmSimilar重发→创建成功", ("相似命中" in simbox) and confirm_ok and ("创建成功" in toasts9) and bool(cid_a), "simbox=%s;toast=%s;夹具A=%s" % (simbox[:70].replace(chr(10), ' '), toasts9[:70], cid_a)) pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(400) # ---- 步 10 编辑客户(version CAS 回显;L2 相似命中→simbox→confirmSimilar 重发,六跑实链) ---- pg.evaluate("() => openDetail('%s')" % cid_a); pg.wait_for_timeout(1600) pg.evaluate("() => dlgEdit()"); pg.wait_for_timeout(1000) old_name = ev(pg, "document.getElementById('eName').value") or "" pg.evaluate("() => { document.getElementById('eName').value = document.getElementById('eName').value + '-改'; }") dlg_btn(pg, "保存") ok10 = wait_api(pg, "/api/customer/edit?id=") pg.wait_for_timeout(1000) sim10 = ev(pg, "(document.querySelector('#eOut .simbox')||{}).textContent || ''") if sim10: # 后端 L2 查重命中(排除自身后仍有相似行)→ demo 确认重发 confirmSimilar=true pg.evaluate("() => { const b = [...document.querySelectorAll('#eOut button')].find(x => x.textContent.indexOf('确认保存') >= 0); if (b) b.click(); }") ok10 = wait_api(pg, "/api/customer/edit?id=") and ok10 pg.wait_for_timeout(1400) toasts10 = read_toast(pg) row = dbq("SELECT customer_name, version FROM customer WHERE id=%s", (int(cid_a),)) if cid_a else [] step("10 编辑客户回显+CAS保存(含相似确认重发)", ok10 and ("保存成功" in toasts10) and bool(row) and str(row[0]["customer_name"]).endswith("-改"), "回显名=%s;simbox=%s;toast=%s;db=%s v%s" % (old_name, ("命中" if sim10 else "-"), toasts10[:40], row[0]["customer_name"] if row else "-", row[0]["version"] if row else "-")) # ---- 步 11 详情头部金额卡〔D〕 ---- pg.wait_for_timeout(900) cards = ev(pg, "[...document.querySelectorAll('#headBox .sumcards .card')].map(c => (c.querySelector('b')||{}).textContent)") or [] p36 = ev(pg, "document.querySelectorAll('#headBox [data-defect=\"P3-6\"]').length") or 0 head = ev(pg, "document.getElementById('headBox').textContent") or "" step("11 详情头部渲染+4金额卡恒--[D]", len(cards) == 4 and all(c in ("--", "-- / --") for c in cards) and p36 >= 1 and "负责人" in head, "cards=%s;P3-6角标=%s" % (cards, p36)) shot(pg, "s11-head.png") # ---- 步 12 8 页签遍历 + 联系人顶级菜单 ---- subs = ev(pg, "[...document.querySelectorAll('#detailPanel .subtabs button')].map(b => b.dataset.s)") or [] errs0 = len(console_errors) for s in ["info", "contacts", "follow", "opps", "projects", "agreements", "members", "oplog"]: pg.evaluate("() => switchSub('%s')" % s); pg.wait_for_timeout(700) pg.evaluate("() => { switchTab('contacts'); loadContactPage(); }") ok12c = wait_api(pg, "contact/page"); pg.wait_for_timeout(1100) ct_rows = ev(pg, "document.querySelectorAll('#ctPageBody tr').length") or 0 pg.evaluate("() => { switchTab('detail'); switchSub('info'); }") pg.wait_for_timeout(900) step("12 8页签全遍历+联系人顶级菜单contact/page", len(subs) == 8 and ok12c and ct_rows > 0 and len(console_errors) == errs0, "subs=%s;contact/page行=%s;新增errs=%s" % (subs, ct_rows, console_errors[errs0:][:2])) # ---- 步 13 页签1 信息栅格 + G4〔D〕 + creditCode 明文 ---- pg.evaluate("() => openDetail('%s')" % SEEDS["full"]); pg.wait_for_timeout(1600) pg.evaluate("() => switchSub('info')"); pg.wait_for_timeout(900) kv = ev(pg, "{ const m = {}; document.querySelectorAll('#subBox .kv').forEach(k => { m[k.querySelector('b').textContent] = k.querySelector('span').textContent; }); return m; }") or {} g4_ok = kv.get("创建人") == "--" and kv.get("更新人") == "--" and kv.get("创建时间", "--") not in ("--", None) and kv.get("更新时间", "--") not in ("--", None) g4b = ev(pg, "document.querySelectorAll('#subBox [data-defect=\"G4\"]').length") or 0 step("13 信息栅格16字段+G4创建/更新人--[D]+creditCode明文", len(kv) >= 16 and g4_ok and g4b >= 1 and kv.get("信用代码") == "91440101E2CTEST001", "kv=%d;创建人=%s;信用代码=%s" % (len(kv), kv.get("创建人"), kv.get("信用代码"))) # ---- 步 14 关联商机 7 列〔D〕+ 关联项目 Noop 空态 + contact/search ---- pg.evaluate("() => switchSub('opps')"); pg.wait_for_timeout(1000) opp_heads = ev(pg, "[...document.querySelectorAll('#subBox thead th')].map(t => t.textContent)") or [] g2b = ev(pg, "document.querySelectorAll('#subBox [data-defect=\"G2\"]').length") or 0 pg.evaluate("() => switchSub('projects')"); pg.wait_for_timeout(1000) proj_hit = wait_api(pg, "customer/project/page") sub14 = ev(pg, "document.getElementById('subBox').textContent") or "" proj_noop = "Noop" in sub14 # 空态真容=表头+Noop hint(K2 口径非硬占位),非「无数据」文案 pg.evaluate("() => dlgContactSearch()"); pg.wait_for_timeout(400) pg.evaluate("() => { document.getElementById('csKw').value = '张关键'; }") dlg_btn(pg, "搜索") wait_api(pg, "contact/search"); pg.wait_for_timeout(900) cs_out = ev(pg, "document.getElementById('csOut').textContent") or "" pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(400) step("14 商机7列(G2-D)+项目页签Noop空态+contact/search命中", len(opp_heads) == 7 and g2b >= 1 and proj_hit and proj_noop and "张关键" in cs_out, "商机列=%s;Noop空态=%s;search=%s" % (opp_heads, proj_noop, cs_out[:60].replace(chr(10), ' '))) # ---- 步 15 团队成员:加 BUDDY → 渲染 → 移除(G3〔D〕) ---- pg.evaluate("() => switchSub('members')"); pg.wait_for_timeout(1000) mheads = ev(pg, "[...document.querySelectorAll('#subBox thead th')].map(t => t.textContent)") or [] g3b = ev(pg, "document.querySelectorAll('#subBox [data-defect=\"G3\"]').length") or 0 pg.evaluate("() => dlgMemberAdd()"); pg.wait_for_timeout(400) pg.evaluate("() => { document.getElementById('mIds').value = '%s'; }" % BUDDY) dlg_btn(pg, "添加") ok15 = wait_api(pg, "member/add") pg.wait_for_timeout(1000) mbox = ev(pg, "document.getElementById('subBox').textContent") or "" m_cnt = dbq("SELECT COUNT(*) AS n FROM customer_team_member WHERE customer_id=%s", (int(SEEDS["full"]),))[0]["n"] removed = pg.evaluate("() => { const b = [...document.querySelectorAll('#subBox button')].find(x => x.textContent === '移除'); if (b) { b.click(); return true; } return false; }") wait_api(pg, "member/remove"); pg.wait_for_timeout(1000) step("15 成员加BUDDY→渲染→移除(G3-D无职务/加入时间列)", ok15 and (BUDDY in mbox or "协同人" in mbox) and m_cnt >= 1 and removed and len(mheads) == 3 and g3b >= 1, "列头=%s;DB成员行=%s;memberUserIds重复键=%s" % (mheads, m_cnt, ok15)) # ---- 步 16 联系人 quickAdd + G5〔D〕 + 脱敏 + reveal + DB 埋点 ---- pg.evaluate("() => switchSub('contacts')"); pg.wait_for_timeout(1600) heads_c = ev(pg, "[...document.querySelectorAll('#contactsShell thead th')].map(t => t.textContent)") or [] g5b = ev(pg, "document.querySelectorAll('#contactsShell [data-defect=\"G5\"]').length") or 0 tmask = ev(pg, "document.getElementById('contactTbody').textContent") or "" masked_ok = "138****0001" in tmask qa_js = ("fetch('http://localhost:8080/api/customer/contact/quickAdd', {method:'POST', " "headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'}, " "body:'customerId=%s&rows[0].name=李冒烟&rows[0].jobTitleCode=%s&rows[0].phone=13800002222&rows[0].source=other&rows[0].isKeyContact=0" "&rows[1].name=赵冒烟&rows[1].jobTitleCode=%s&rows[1].source=other'}).then(r => r.json()).then(d => { window.__qaRet = d; })" % (SEEDS["full"], jobc, jobc)) pg.evaluate("() => %s" % qa_js) wait_api(pg, "contact/quickAdd"); pg.wait_for_timeout(1400) # quickAdd 是页面 fetch 直调,不触发 loadContacts → window._contacts 停留旧快照(六跑 lid=None 连锁)→ 显式重拉 pg.evaluate("() => loadContacts()"); pg.wait_for_timeout(1300) qa_ret = ev(pg, "JSON.stringify((window.__qaRet||{}).data||{})") or "" rows2 = dbq("SELECT COUNT(*) AS n FROM customer_contact WHERE customer_id=%s AND deleted=0", (int(SEEDS["full"]),))[0]["n"] pg.evaluate("() => toggleRevealAll()") wait_api(pg, "contact/reveal"); pg.wait_for_timeout(1300) treveal = ev(pg, "document.getElementById('contactTbody').textContent") or "" zid = ev(pg, "(window._contacts.find(c => c.name === '张关键') || {}).id") rl = dbq("SELECT COUNT(*) AS n FROM customer_contact_reveal_log WHERE contact_id=%s", (int(zid),))[0]["n"] if zid else 0 step("16 联系人quickAdd2行+G5无负责人列[D]+电话脱敏+reveal明文+DB埋点", masked_ok and rows2 >= 4 and rl >= 1 and g5b >= 1 and "13812340001" in treveal and not any(h == "负责人" for h in heads_c), "脱敏=%s;DB联系人=%s;reveal_log=%s;明文=%s;列头=%s;qaRet=%s" % (masked_ok, rows2, rl, "13812340001" in treveal, heads_c, qa_ret[:80])) # ---- 步 17 batchEdit 空串提交〔D-G1〕 + check-phone + 删除双刷 ---- pg.evaluate("() => editListToggle()"); pg.wait_for_timeout(700) pg.evaluate("() => { const c = window._contacts.find(x => x.name === '张关键'); c.phone = ''; C.dirtyRows[String(c.id)] = true; renderContactsList(); }") pg.evaluate("() => saveListAll()") ok17 = wait_api(pg, "contact/batchEdit"); pg.wait_for_timeout(1100) ph = dbq("SELECT phone FROM customer_contact WHERE id=%s", (int(zid),))[0]["phone"] if zid else "" dg1 = ev(pg, "document.querySelectorAll('#contactsShell [data-defect=\"D-G1\"]').length") or 0 pg.evaluate("() => { if (C.editing) editListToggle(); }"); pg.wait_for_timeout(500) # check-phone 命中王重复(同号 13812340001) pg.evaluate("() => dlgCheckPhone('13812340001')"); pg.wait_for_timeout(400) dlg_btn(pg, "查重") wait_api(pg, "check-phone"); pg.wait_for_timeout(900) cp = ev(pg, "document.getElementById('cpOut').textContent") or "" pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(400) # 删除李冒烟(gConfirm 自绘确认 → contact/delete → 列表+图谱双刷) lid = ev(pg, "(window._contacts.find(c => c.name === '李冒烟') || {}).id") names16 = ev(pg, "(window._contacts || []).map(c => c.name).join(',')") or "" errs0 = len(console_errors) del_ok = False if lid: pg.evaluate("() => delContact('%s')" % lid); pg.wait_for_timeout(600) del_ok = dlg_btn(pg, "确认") or dlg_btn(pg, "确定") wait_api(pg, "contact/delete"); pg.wait_for_timeout(1600) rows3 = dbq("SELECT COUNT(*) AS n FROM customer_contact WHERE customer_id=%s AND deleted=0", (int(SEEDS["full"]),))[0]["n"] step("17 batchEdit空串=DB不改(D-G1)[D]+check-phone命中+删除双刷", ok17 and ph == "13812340001" and dg1 >= 1 and bool(cp) and del_ok and rows3 == rows2 - 1 and len(console_errors) == errs0, "DB phone=%s;D-G1角标=%s;check-phone=%s;删后行=%s(删前%s,del_ok=%s,errs+%s);lid=%s;names=%s" % (ph, dg1, cp[:40].replace(chr(10), ' '), rows3, rows2, del_ok, len(console_errors) - errs0, lid, names16[:60])) # ---- 步 18 跟进:写 + 日期分组 + followWay 筛选 ---- pg.evaluate("() => switchSub('follow')"); pg.wait_for_timeout(1000) fopts = ev(pg, "[...document.getElementById('fFollowWay').options].map(o => o.value)") or [] pg.evaluate("() => dlgFollow()"); pg.wait_for_timeout(400) pg.evaluate("() => { document.getElementById('fContent').value = '冒烟跟进内容-电话沟通'; }") dlg_btn(pg, "保存") ok18 = wait_api(pg, "follow/add") pg.wait_for_timeout(1300) days = ev(pg, "document.querySelectorAll('#followBox .logday').length") or 0 pg.evaluate("() => { const s = document.getElementById('fFollowWay'); s.value = 'follow_way_01'; s.onchange(); }") flt18 = wait_api(pg, "followWay=follow_way_01"); pg.wait_for_timeout(700) step("18 写跟进+日期分组+followWay筛选", ok18 and len(fopts) == 6 and days >= 1 and flt18, "way选项=%s(空+5);日期分组=%s;筛选=%s" % (len(fopts), days, flt18)) # ---- 步 19 focus/star + oplog 留痕 ---- pg.evaluate("() => oneBy('focus')"); wait_api(pg, "/focus?id="); pg.wait_for_timeout(700) pg.evaluate("() => oneBy('star')"); wait_api(pg, "/star?id="); pg.wait_for_timeout(700) pg.evaluate("() => switchSub('oplog')"); pg.wait_for_timeout(1100) obox = ev(pg, "document.getElementById('oplogBox').textContent") or "" pg.evaluate("() => { const s = document.getElementById('fOpAction'); s.value = 'FOLLOW'; s.onchange(); }") flt19 = wait_api(pg, "action=FOLLOW"); pg.wait_for_timeout(700) # 创建留痕不在首页(oplog 时间倒序被本轮操作挤掉)→ 用 action=CREATE 筛选强断言 pg.evaluate("() => { const s = document.getElementById('fOpAction'); s.value = 'CREATE'; s.onchange(); }") wait_api(pg, "action=CREATE"); pg.wait_for_timeout(800) obox2 = ev(pg, "document.getElementById('oplogBox').textContent") or "" f_cnt = dbq("SELECT COUNT(*) AS n FROM customer_follow WHERE customer_id=%s", (int(SEEDS["full"]),))[0]["n"] step("19 关注/重点标记+oplog留痕+action筛选", ("新增客户" in obox2) and flt19 and f_cnt >= 1, "action=CREATE筛选含新增客户=%s;action=FOLLOW筛选=%s;DB跟进=%s" % ("新增客户" in obox2, flt19, f_cnt)) shot(pg, "s19-oplog.png") # ================= B · 重流程(步 20-25) ================= # ---- 步 20 客户导入三段式(模板已在 build_xlsx 下载改制;UI 上传 → route 分流 → 确认 → 轮询 → 失败明细) ---- pg.evaluate("() => switchTab('import')"); pg.wait_for_timeout(900) pg.set_input_files("#impFile", XLSX_CUST) pg.evaluate("() => { document.getElementById('impMode').value = 'APPEND_ONLY'; document.getElementById('impStrategy').value = 'SKIP'; }") pg.evaluate("() => impUpload()") ok20 = wait_api(pg, "customer/import/upload") pg.wait_for_timeout(1600) toasts20 = read_toast(pg) m = re.search(r"t=(\d+) insert=(\d+) update=(\d+) fail=(\d+)", toasts20) t_total, t_ins, t_fail = (int(m.group(1)), int(m.group(2)), int(m.group(4))) if m else (0, 0, 0) tid = ev(pg, "{ const b = [...document.querySelectorAll('#impBody button')].find(x => x.textContent === '确认执行');" "if (!b) return ''; const mm = (b.getAttribute('onclick') || '').match(/'([^']+)'/); return mm ? mm[1] : ''; }") if tid: pg.evaluate("() => impConfirm('%s')" % tid) wait_api(pg, "import/confirm"); pg.wait_for_timeout(1500) res = {} for _ in range(30): try: res = get_json(f"{BASE}/api/customer/import/result?taskId={tid}", tok).get("data") or {} if res.get("status") in (2, 3): break except Exception: pass time.sleep(1) fails = [] try: fd = get_json(f"{BASE}/api/customer/import/failures?taskId={tid}", tok).get("data") fails = fd if isinstance(fd, list) else ((fd or {}).get("content") or []) except Exception: pass step("20 客户导入三段式(缺省份预检FAIL→DONE计数=预检→明细吻合)", ok20 and bool(tid) and t_ins >= 1 and t_fail >= 1 and res.get("status") == 2 and int(res.get("insertCount") or 0) == t_ins and int(res.get("failCount") or 0) == t_fail and len(fails) == t_fail, "预检 t=%s ins=%s fail=%s;taskId=%s;result=%s;明细=%s行" % (t_total, t_ins, t_fail, tid, res, len(fails))) shot(pg, "s20-import.png") # ---- 步 21 任务列表 + status/importMode 过滤 + P3-3〔D〕 ---- pg.evaluate("() => { const a = document.getElementById('impFStatus'); a.value = '2'; a.onchange(); }") f21a = wait_api(pg, "import/page"); pg.wait_for_timeout(900) pg.evaluate("() => { const a = document.getElementById('impFMode'); a.value = 'UPSERT'; a.onchange(); }") f21b = wait_api(pg, "importMode=UPSERT"); pg.wait_for_timeout(900) p33 = ev(pg, "document.querySelectorAll('#tab-import [data-defect=\"P3-3\"]').length") or 0 step("21 导入任务列表+status/importMode过滤+P3-3注记[D]", f21a and f21b and p33 >= 1, "status过滤=%s;mode过滤=%s;P3-3=%s" % (f21a, f21b, p33)) # ---- 步 22 联系人导入三段式(cimp 弹窗;P3-3 无校验复选框〔D〕) ---- pg.evaluate("() => { switchTab('detail'); openDetail('%s'); }" % cid_a); pg.wait_for_timeout(1600) pg.evaluate("() => switchSub('contacts')"); pg.wait_for_timeout(1300) pg.evaluate("() => openCimp()"); pg.wait_for_timeout(700) cimp_chk = ev(pg, "document.querySelectorAll('#cimpOverlay input[type=checkbox]').length") or 0 pg.set_input_files("#cimpFile", XLSX_CONT) pg.evaluate("() => { document.getElementById('cimpMode').value = 'APPEND_ONLY'; document.getElementById('cimpStrategy').value = 'SKIP'; }") pg.evaluate("() => cimpUpload()") ok22 = wait_api(pg, "contact/import/upload") pg.wait_for_timeout(1600) prev22 = ev(pg, "document.getElementById('cimpPreviewHead').textContent") or "" pg.evaluate("() => cimpConfirmTask()") wait_api(pg, "contact/import/confirm"); pg.wait_for_timeout(1500) ctid = ev(pg, "cimpTaskId") or "" cres = {} for _ in range(30): try: cres = get_json(f"{BASE}/api/customer/contact/import/result?taskId={ctid}", tok).get("data") or {} if cres.get("status") in (2, 3): break except Exception: pass time.sleep(1) pg.wait_for_timeout(1200) # demo 自身 1s 轮询同步 done22 = ev(pg, "document.getElementById('cimpResultHead').textContent") or "" c_cnt = dbq("SELECT COUNT(*) AS n FROM customer_contact WHERE customer_id=%s AND deleted=0", (int(cid_a),))[0]["n"] if cid_a else 0 pg.evaluate("() => cimpDone()") step("22 联系人导入三段式+P3-3无校验复选框[D]", ok22 and bool(ctid) and cres.get("status") == 2 and "已完成" in done22 and c_cnt >= 2 and cimp_chk == 0, "预检=%s;result=%s;DB联系人=%s;弹窗checkbox=%s" % (prev22[:60].replace(chr(10), ' '), cres, c_cnt, cimp_chk)) # ---- 步 23 商机联动链:UI 建商机 → 抛公海 67007 → API 关商机 → 放行 ---- pg.evaluate("() => { switchTab('list'); switchWs('mine'); }"); pg.wait_for_timeout(1500) pg.evaluate("() => { document.getElementById('fKeyword').value = 'e2c-dr-恒信达'; S.page = 1; loadList(); }") pg.wait_for_timeout(1600) pg.evaluate("() => dlgOppCreate('%s')" % cid_a); pg.wait_for_timeout(600) pg.evaluate("() => { document.getElementById('oName').value = 'e2c-dr-演示商机';" "document.getElementById('oSource').value = 'opp_source_02';" "document.getElementById('oBid').value = 'bid_form_01';" "document.getElementById('oLoc').value = 'locality_type_01';" "document.getElementById('oProv').value = '440000'; document.getElementById('oCity').value = '440100';" "document.getElementById('oPrimary').value = '1'; document.getElementById('oRole').value = 'customer_role_01'; }") n0 = len(api_urls) pg.evaluate("() => { const b = [...document.querySelectorAll('#dlgFoot button')].find(x => x.textContent === '创建商机'); if (b) b.click(); }") ok23 = wait_api(pg, "POST /api/opportunity") pg.wait_for_timeout(1400) toasts23 = read_toast(pg) opp = dbq("SELECT opportunity_id FROM opportunity_customer WHERE customer_id=%s ORDER BY opportunity_id DESC LIMIT 1", (int(cid_a),)) if cid_a else [] opp_id = str(opp[0]["opportunity_id"]) if opp else "" opp0 = dbq("SELECT opp_status FROM opportunity WHERE id=%s", (int(opp_id),))[0]["opp_status"] if opp_id else None pg.evaluate("() => { S.picked.clear(); pick('%s'); }" % cid_a) picked23 = ev(pg, "S.picked.size") or 0 pg.evaluate("() => batchRelease()") wait_api(pg, "release-pool-batch"); pg.wait_for_timeout(1300) t67007 = read_toast(pg) # 拦截生效的证明=客户仍在名下(批量部分成功语义:67007 映射 failures.POOL_BLOCKED,HTTP 200 无错误码 toast) cust1 = dbq("SELECT owner_user_id FROM customer WHERE id=%s", (int(cid_a),)) if cid_a else [] blocked = bool(cust1) and cust1[0]["owner_user_id"] is not None if opp_id: try: post(f"{BASE}/api/opportunity/assign?id={opp_id}&userId={ADMIN}", {}, tok) # 新建即推进中(设计),CAS 失败无碍 except Exception: pass post(f"{BASE}/api/opportunity/close?id={opp_id}&closeReason={urllib.parse.quote('冒烟演示关闭')}", {}, tok) opp1 = dbq("SELECT opp_status FROM opportunity WHERE id=%s", (int(opp_id),))[0]["opp_status"] if opp_id else None pg.evaluate("() => batchRelease()") wait_api(pg, "release-pool-batch"); pg.wait_for_timeout(1600) cust_db = dbq("SELECT owner_user_id, enter_pool_time FROM customer WHERE id=%s", (int(cid_a),)) if cid_a else [] in_pool = bool(cust_db) and cust_db[0]["owner_user_id"] is None and cust_db[0]["enter_pool_time"] is not None step("23 商机联动链(UI建商机→抛公海拦截→关商机→放行)", ok23 and bool(opp_id) and picked23 >= 1 and blocked and str(opp1) != str(opp0) and in_pool, "商机=%s;状态%s→%s;一次release拦截=%s(toast=%s);进公海=%s" % (opp_id, opp0, opp1, blocked, t67007[:60].replace(chr(10), ' '), in_pool)) # ---- 步 24 交割两段式(F-13 防线:initiate 圈定=发起人名下全量,先清 BUDDY leftover) ---- leftovers = dbq("SELECT id, customer_name FROM customer WHERE owner_user_id=%s AND deleted=0 AND customer_name NOT LIKE %s", (int(BUDDY), PFX + "%")) moved = 0 for lo in leftovers: try: d = post(f"{BASE}/api/customer/assign-batch?ids={lo['id']}&userId={ADMIN}", {}, tok).get("data") or {} moved += int(d.get("successCount") or 0) except Exception as e: print(" [warn] leftover 回迁失败 %s: %s" % (lo["customer_name"], e)) post(f"{BASE}/api/customer/claim-batch?ids={cid_a}", {}, tok) # 公海领回(owner=ADMIN) post(f"{BASE}/api/customer/assign-batch?ids={cid_a}&userId={BUDDY}", {}, tok) # 夹具 A → BUDDY btok = get_token(BUDDY) prev24 = get_json(f"{BASE}/api/customer/transfer/preview", btok).get("data") prev_rows = prev24 if isinstance(prev24, list) else ((prev24 or {}).get("customers") or (prev24 or {}).get("content") or []) prev_has = any("e2c-dr" in str(c.get("customerName") or c.get("customer_name") or "") for c in prev_rows) init = (post(f"{BASE}/api/customer/transfer/initiate", {"reason": "resign", "remark": "冒烟交割"}, btok) or {}) init_d = init.get("data") or {} if not init_d: print(" [warn] initiate 完整响应: %s" % str(init)[:240]) bill_no = str(init_d.get("transferNo") or "") bill_id = str(init_d.get("id") or init_d.get("transferId") or "") page24 = get_json(f"{BASE}/api/customer/transfer/page?current=1&size=10", btok).get("data") or {} page_rows = page24.get("content") or [] page_hit = any(str(r.get("transferNo")) == bill_no for r in page_rows) if not bill_id: for r in page_rows: if str(r.get("transferNo")) == bill_no: bill_id = str(r.get("id")); break det = get_json(f"{BASE}/api/customer/transfer/detail?id={bill_id}", btok).get("data") or {} if bill_id else {} items = det.get("items") or det.get("details") or [] asg = get_json(f"{BASE}/api/customer/transfer/assignable?id={bill_id}", btok).get("data") if bill_id else None asg_rows = asg if isinstance(asg, list) else ((asg or {}).get("content") or (asg or {}).get("users") or []) # 分配对象从 assignable 动态取:后端排除发起人与总监本人(CustomerTransferServiceImpl L132), # 五跑硬编码 ADMIN=总监本人被拒 → assign_status 不变 asg_uid = "" if asg_rows: asg_uid = str((asg_rows[0] or {}).get("userId") or (asg_rows[0] or {}).get("id") or "") asg24 = {} if bill_id and asg_uid: asg24 = post(f"{BASE}/api/customer/transfer/assign?id={bill_id}&customerIds={cid_a}&assignUserId={asg_uid}", {}, btok) or {} if asg24 and asg24.get("code") != 0: print(" [warn] transfer/assign 响应: %s" % str(asg24)[:220], flush=True) det2 = get_json(f"{BASE}/api/customer/transfer/detail?id={bill_id}", btok).get("data") or {} if bill_id else {} items2 = det2.get("items") or det2.get("details") or [] st_change = any(str(it.get("customerId")) == str(cid_a) and int(it.get("assignStatus") or 0) == 1 for it in items2) db_bill = dbq("SELECT id, transfer_no, status FROM customer_transfer WHERE transfer_no=%s", (bill_no,)) if bill_no else [] pg.evaluate("() => switchTab('transfer')"); pg.wait_for_timeout(300) pg.evaluate("() => transferPage(0)") wait_api(pg, "transfer/page"); pg.wait_for_timeout(1000) tr_rows = ev(pg, "document.querySelectorAll('#trBody tr').length") or 0 step("24 交割(preview圈定→initiate→page→detail→assignable→assign明细态变化;F-13清leftover=%s)" % moved, prev_has and bool(bill_no) and page_hit and bool(items) and bool(asg_rows) and st_change and bool(db_bill), "单号=%s;圈定含夹具=%s;明细=%s→assign后变化=%s(assign对象=%s);UI行=%s" % (bill_no, prev_has, len(items), st_change, asg_uid, tr_rows)) shot(pg, "s24-transfer.png") # ---- 步 25 归属动作族:导入客户先抛公海 → pool 勾选 → claim → archive → restore → 再 archive + assign ---- imp_ids = [str(r["id"]) for r in dbq("SELECT id FROM customer WHERE customer_name LIKE %s AND deleted=0", ("e2c-dr-imp%",))] rel25 = {} if imp_ids: # 步 20 导入客户落在 ADMIN 名下 → pool 视图(owner IS NULL)搜不到(五跑「请先勾选客户行」根因)→ 先 release 进公海 rel25 = post(f"{BASE}/api/customer/release-pool-batch?ids={','.join(imp_ids)}", {}, tok) or {} if rel25.get("code") != 0: print(" [warn] release 进公海响应: %s" % str(rel25)[:200], flush=True) inpool25 = all(r["owner_user_id"] is None for r in dbq( "SELECT owner_user_id FROM customer WHERE id IN (%s)" % ",".join(imp_ids))) if imp_ids else False pg.evaluate("() => { switchTab('list'); switchWs('pool'); }"); pg.wait_for_timeout(1500) # pool+ASSIGNED 组合语义冲突:视图「我负责的」(owner=me) 与公海 (owner IS NULL) 恒互斥 # (CustomerMapper L83)→ 先按 UI 默认呈现空现象,再清 fViewType 走 pool 基础集(demo 视图条在 pool 页签仍显示) pg.evaluate("() => { document.getElementById('fKeyword').value = 'e2c-dr-imp'; S.page = 1; loadList(); }") pg.wait_for_timeout(1600) rows_def25 = ev(pg, "S.rows.length") or 0 pg.evaluate("() => { document.getElementById('fViewType').value = ''; S.page = 1; loadList(); }") pg.wait_for_timeout(1600) rows25 = ev(pg, "S.rows.length") or 0 pg.evaluate("() => { S.picked.clear(); S.rows.forEach(r => pick(String(r.id))); }") picked25 = ev(pg, "S.picked.size") or 0 pg.evaluate("() => batchClaim()") ok25a = wait_api(pg, "claim-batch"); pg.wait_for_timeout(1300) t25a = read_toast(pg) # batchClaim 内部清空 picked → UI dlgAssign(要求先行勾选)无法衔接 → assign 移到 archive 族后用 API 直调覆盖端点 pg.evaluate("() => batchArchive()") ok25c = wait_api(pg, "archive-batch"); pg.wait_for_timeout(1300) t25c = read_toast(pg) arch25 = all(str(r["archive_status"]) == "2" for r in dbq( "SELECT archive_status FROM customer WHERE id IN (%s)" % ",".join(imp_ids))) if imp_ids else False st1 = st2 = None if imp_ids: pg.evaluate("() => openDetail('%s')" % imp_ids[0]); pg.wait_for_timeout(1600) pg.evaluate("() => oneBy('restore')"); wait_api(pg, "/restore?id="); pg.wait_for_timeout(1000) st1 = dbq("SELECT archive_status FROM customer WHERE id=%s", (int(imp_ids[0]),))[0]["archive_status"] pg.evaluate("() => oneBy('archive')"); wait_api(pg, "/archive?id="); pg.wait_for_timeout(1000) st2 = dbq("SELECT archive_status FROM customer WHERE id=%s", (int(imp_ids[0]),))[0]["archive_status"] oplog25 = dbq("SELECT COUNT(*) AS n FROM customer_oplog WHERE customer_id=%s AND action IN ('ARCHIVE','RESTORE')", (int(imp_ids[0]),))[0]["n"] if imp_ids else 0 asg25 = post(f"{BASE}/api/customer/assign-batch?ids={','.join(imp_ids)}&userId={BUDDY}", {}, tok) if imp_ids else {} d25 = (asg25.get("data") or {}) if isinstance(asg25, dict) else {} ok25b = isinstance(asg25, dict) and asg25.get("code") == 0 and int(d25.get("successCount") or 0) >= 1 as_out = "success=%s fail=%s" % (d25.get("successCount"), d25.get("failCount")) step("25 归属动作族(release→claim→archive→restore→再archive+oplog+assign)", ok25a and ok25b and ok25c and picked25 >= 1 and arch25 and st1 == 1 and st2 == 2 and oplog25 >= 2 and inpool25 and rows25 >= 1, "进公海=%s(pool默认视图行=%s,清视图后行=%s);claim=%s(%s);archive=%s(%s);assign=%s(%s);恢复%s→再归档%s;oplog=%s" % (inpool25, rows_def25, rows25, ok25a, t25a[:40], ok25c, t25c[:40], ok25b, as_out[:40], st1, st2, oplog25)) # ================= C · 规则族(步 26-29) ================= d_orig = get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {} thr0 = d_orig.get("similarityThreshold") mode0 = d_orig.get("nameMatchMode") pg.evaluate("() => switchTab('settings')"); pg.wait_for_timeout(1300) # saveDedup 从 dpMode 下拉收集:未 loadDedup 时停留 HTML 默认 1 → save 写 1 污染共享库(六跑实锤)→ save 前显式填充 pg.evaluate("() => loadDedup()"); wait_api(pg, "rule/customer/dedup"); pg.wait_for_timeout(600) pg.evaluate("() => { document.getElementById('dpThreshold').value = '90'; saveDedup(); }") wait_api(pg, "dedup/save"); pg.wait_for_timeout(900) thr1 = (get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {}).get("similarityThreshold") pg.evaluate("() => { document.getElementById('dpThreshold').value = '%s'; saveDedup(); }" % (thr0 if thr0 is not None else 80)) wait_api(pg, "dedup/save"); pg.wait_for_timeout(900) thr2 = (get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {}).get("similarityThreshold") mode_end = (get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {}).get("nameMatchMode") step("26 查重设置读→改90→save生效→还原(含匹配方式不被污染)", thr1 == 90 and thr2 == thr0 and mode_end == mode0, "原=%s→90→还原=%s;mode=%s(原%s)" % (thr0, thr2, mode_end, mode0)) # ---- 步 27 check-name 命中 + 信用代码撞码 67003 ---- pg.evaluate("() => { document.getElementById('ckName').value = 'e2c-dr-恒信达科技有限责任公司'; checkName(); }") wait_api(pg, "check-name"); pg.wait_for_timeout(900) ck27 = ev(pg, "document.getElementById('ckOut').textContent") or "" # check-credit-code L1 直调(REQUIRED 端点矩阵覆盖;撞码占用者=e2c-全字段-科技 seed) ckc27 = (get_json(f"{BASE}/api/customer/check-credit-code?creditCode=91440101E2CTEST001", tok).get("data") or {}) pg.evaluate("() => { switchTab('list'); dlgCreate(); }"); pg.wait_for_timeout(600) pg.evaluate("() => { document.getElementById('cName').value = 'e2c-dr-撞码客户';" "document.getElementById('cType').value = '%s'; document.getElementById('cProv').value = '440000';" "document.getElementById('cCity').value = '440100'; document.getElementById('cDist').value = '440103';" "document.getElementById('cInd').value = '%s'; document.getElementById('cCredit').value = '91440101E2CTEST001'; }" % (ctype, gov)) pg.evaluate("() => doCreate('')") wait_api(pg, "/api/customer/create"); pg.wait_for_timeout(1100) err27 = ev(pg, "(document.querySelector('#cOut .errbox')||{}).textContent || ''") or "" pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(300) step("27 check-name命中e2c-恒信达+信用代码撞码67003(L3硬拦)+check-credit-code L1", "恒信达" in ck27 and "67003" in err27 and bool(ckc27.get("exists")), "check-name=%s;err=%s;creditCode.exists=%s" % (ck27[:60].replace(chr(10), ' '), err27[:90], ckc27.get("exists"))) # ---- 步 28 超期提醒:save 非法 0 → 64023 → 还原 ---- r_orig = get_json(f"{BASE}/api/rule/customer/reminder", tok).get("data") or {} f0 = r_orig.get("firstTriggerDays"); s0 = r_orig.get("secondIntervalDays") pg.evaluate("() => switchTab('settings')"); pg.wait_for_timeout(1300) pg.evaluate("() => { document.getElementById('rFirstDays').value = '0'; saveReminder(); }") wait_api(pg, "reminder/save"); pg.wait_for_timeout(900) got64023 = "64023" in read_toast(pg) pg.evaluate("() => { document.getElementById('rFirstDays').value = '%s'; document.getElementById('rSecondDays').value = '%s'; saveReminder(); }" % (f0 if f0 else 30, s0 if s0 else 7)) wait_api(pg, "reminder/save"); pg.wait_for_timeout(900) r_end0 = get_json(f"{BASE}/api/rule/customer/reminder", tok).get("data") or {} step("28 超期提醒save非法0→64023toast→还原30/7", got64023 and r_end0.get("firstTriggerDays") == f0 and r_end0.get("secondIntervalDays") == s0, "64023=%s;还原=%s/%s(原%s/%s)" % (got64023, r_end0.get("firstTriggerDays"), r_end0.get("secondIntervalDays"), f0, s0)) # ---- 步 29 POOL-CFG 占位卡只读〔D〕 ---- pool29 = ev(pg, "document.querySelectorAll('#tab-settings [data-defect=\"POOL-CFG\"]').length") or 0 dis29 = ev(pg, "document.querySelectorAll('#tab-settings input[disabled]').length") or 0 step("29 公海池配置占位卡只读[D]", pool29 >= 1 and dis29 >= 2, "角标=%s;禁用输入=%s" % (pool29, dis29)) # ================= D · 缺陷现象(步 30-36) ================= pg.evaluate("() => defectPanel()"); pg.wait_for_timeout(800) decl = ev(pg, "document.querySelectorAll('#dlg [data-defect-decl]').length") or 0 cnts = ev(pg, "({ a: DEFECT_LEDGER.open.length, b: DEFECT_LEDGER.fixed.length, c: DEFECT_LEDGER.exempt.length, d: DEFECT_LEDGER.env.length })") or {} rows30 = ev(pg, "document.querySelectorAll('#dlg [data-defect-row]').length") or 0 pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(300) step("30 缺陷台账面板(A≥14/B4/C14/D1+固定声明)", decl >= 1 and cnts.get("a", 0) >= 14 and cnts.get("b") == 4 and cnts.get("c") == 14 and cnts.get("d") == 1, "A=%s B=%s C=%s D=%s;渲染行=%s" % (cnts.get("a"), cnts.get("b"), cnts.get("c"), cnts.get("d"), rows30)) shot(pg, "s30-ledger.png") # 角标互斥分布于各页签/弹窗(G1=follow子页签、G2=opps、G3=members、G5/D-G1=contactsShell、 # P3-3=import、POOL-CFG=settings、I-06=list、STUB-LOOKUP=建客户弹窗、P3-6/G4=info)—— # 单页签瞬时统计必然缺类目(五跑 9<12 教训)→ 多页签采样累计 kinds31, badges31 = set(), 0 for jmp in ["() => switchTab('settings')", "() => switchTab('import')", "() => { switchTab('list'); switchWs('pool'); }"]: pg.evaluate(jmp); pg.wait_for_timeout(1100) kinds31 |= set(ev(pg, "[...document.querySelectorAll('[data-defect]')].map(x => x.dataset.defect)") or []) badges31 += ev(pg, "document.querySelectorAll('[data-defect]').length") or 0 pg.evaluate("() => dlgCreate()"); pg.wait_for_timeout(600) kinds31 |= set(ev(pg, "[...document.querySelectorAll('[data-defect]')].map(x => x.dataset.defect)") or []) badges31 += ev(pg, "document.querySelectorAll('[data-defect]').length") or 0 pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(300) pg.evaluate("() => { switchWs('mine'); switchTab('detail'); openDetail('%s'); }" % cid_a); pg.wait_for_timeout(1600) for sub in ["info", "follow", "opps", "members", "contacts"]: pg.evaluate("() => switchSub('%s')" % sub); pg.wait_for_timeout(900) kinds31 |= set(ev(pg, "[...document.querySelectorAll('[data-defect]')].map(x => x.dataset.defect)") or []) badges31 += ev(pg, "document.querySelectorAll('[data-defect]').length") or 0 step("31 data-defect角标≥12且关键类目齐全(多页签累计)", badges31 >= 12 and all(x in kinds31 for x in ["G1", "G5", "P3-3", "D-G1", "I-06", "POOL-CFG", "STUB-LOOKUP"]), "累计=%s;类目=%s" % (badges31, sorted(kinds31))) pg.evaluate("() => { switchTab('list'); switchWs('pool'); }"); pg.wait_for_timeout(1500) fopts32 = ev(pg, "[...document.getElementById('fForm').options].map(o => o.value)") or [] i06v = ev(pg, "document.getElementById('i06Note').textContent") or "" step("32 I-06双现象(pool无board选项+注记含67001/记忆成功)", "board" not in fopts32 and "67001" in i06v and "记忆成功" in i06v, "fForm选项=%s" % fopts32) pg.evaluate("() => switchWs('mine')"); pg.wait_for_timeout(1200) exempt_hit = ev(pg, "{ const bad = ['换负责人', '日志导出']; let n = 0; " "document.querySelectorAll('button').forEach(b => { if (bad.some(x => b.textContent.indexOf(x) >= 0)) n++; }); " "document.querySelectorAll('input[type=checkbox], input[type=radio]').forEach(c => { " "const t = (c.previousElementSibling ? c.previousElementSibling.textContent : '') + (c.parentElement ? c.parentElement.textContent : ''); " "if (t.indexOf('完整金额') >= 0 || t.indexOf('触发校验规则') >= 0) n++; }); return n; }") step("33 豁免入口不出现(换负责人/日志导出/完整金额开关/导入校验复选框)", exempt_hit == 0, "命中=%s" % exempt_hit) pg.evaluate("() => openDetail('%s')" % SEEDS["full"]); pg.wait_for_timeout(1600) pg.evaluate("() => switchSub('follow')"); pg.wait_for_timeout(1100) g1_ok = ev(pg, "document.querySelectorAll('#subBox [data-defect=\"G1\"]').length") >= 1 and ev(pg, "document.querySelectorAll('#subBox select').length") == 1 pg.evaluate("() => switchSub('opps')"); pg.wait_for_timeout(1100) g2_ok = ev(pg, "document.querySelectorAll('#subBox thead th').length") == 7 pg.evaluate("() => switchSub('members')"); pg.wait_for_timeout(1100) g3_ok = ev(pg, "document.querySelectorAll('#subBox thead th').length") == 3 and ev(pg, "document.querySelectorAll('#subBox [data-defect=\"G3\"]').length") >= 1 pg.evaluate("() => switchSub('info')"); pg.wait_for_timeout(1100) kv34 = ev(pg, "{ const m = {}; document.querySelectorAll('#subBox .kv').forEach(k => { m[k.querySelector('b').textContent] = k.querySelector('span').textContent; }); return m; }") or {} g4_ok = kv34.get("创建人") == "--" and kv34.get("更新人") == "--" pg.evaluate("() => switchSub('contacts')"); pg.wait_for_timeout(1500) g5_ok = (not ev(pg, "[...document.querySelectorAll('#contactsShell thead th')].some(t => t.textContent === '负责人')") and ev(pg, "document.querySelectorAll('#contactsShell [data-defect=\"G5\"]').length") >= 1) zid2 = ev(pg, "(window._contacts[0] || {}).id") g6_ok = False if zid2: pg.evaluate("() => dlgContactDetail('%s')" % zid2); pg.wait_for_timeout(1000) pg.evaluate("() => ctSubTab(1)"); pg.wait_for_timeout(400) g6_ok = ev(pg, "document.getElementById('ctPaneLog').style.display !== 'none' && document.getElementById('ctPaneLog').textContent.indexOf('无数据源') >= 0") pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(300) step("34 G系列合并复核(G1无操作人筛选/G2七列/G3两列缺/G4--[D]/G5无负责人列/G6空态)", g1_ok and g2_ok and g3_ok and g4_ok and g5_ok and g6_ok, "G1=%s G2=%s G3=%s G4=%s G5=%s G6=%s" % (g1_ok, g2_ok, g3_ok, g4_ok, g5_ok, g6_ok)) cards35 = ev(pg, "[...document.querySelectorAll('#headBox .sumcards .card b')].map(b => b.textContent)") or [] pg.evaluate("() => switchSub('projects')"); pg.wait_for_timeout(1100) pr35 = "Noop" in (ev(pg, "document.getElementById('subBox').textContent") or "") step("35 金额卡P3-6四卡--口径+关联项目Noop空态", len(cards35) == 4 and all(c in ("--", "-- / --") for c in cards35) and pr35, "cards=%s" % cards35) f6_ok = ev(pg, "[...document.querySelectorAll('#tab-list button')].some(b => b.textContent.indexOf('F6 stub') >= 0)") pg.evaluate("() => dlgCreate()"); pg.wait_for_timeout(500) pg.evaluate("() => { document.getElementById('cLk').value = '测试联想词'; doLookup(); }") wait_api(pg, "company-lookup"); pg.wait_for_timeout(900) nohit36 = "未查询到匹配企业" in (ev(pg, "document.getElementById('cOut').textContent") or "") pg.evaluate("() => dlgClose()"); pg.wait_for_timeout(300) step("36 company-lookup NO_HIT+F6 stub文案", nohit36 and f6_ok, "NO_HIT=%s;F6stub=%s" % (nohit36, f6_ok)) # ================= E · 网络层全局 + 清理(步 37-40) ================= step("37 console/pageerror=0", len(console_errors) == 0, ("; ".join(console_errors[:2]) + " | HTTP4xx: " + "; ".join(http_fails[:3])) if (console_errors or http_fails) else "无错误") bad_m = [u for u in api_urls if u.startswith("PUT ") or u.startswith("DELETE ")] step("38 零PUT/DELETE(ADR-0017)", len(bad_m) == 0, "违例=%s" % bad_m[:3]) REQUIRED = ["workspace/page", "preference/view/list", "preference/view/save", "preference/view/delete", "view-form/get", "view-form/save", "board/summary", "board/cards", "company-lookup", "customer/create", "customer/edit?id=", "customer/detail-head", "customer/detail?id=", "customer/opportunity/page", "customer/project/page", "contact/page", "contact/detail?id=", "contact/search", "contact/quickAdd", "contact/reveal", "contact/batchEdit", "contact/check-phone", "contact/delete", "member/add", "member/remove", "follow/add", "oplog/page", "import/upload", "import/confirm", "import/page", "contact/import/upload", "contact/import/confirm", "POST /api/opportunity", "opportunity/assign", "opportunity/close", "release-pool-batch", "claim-batch", "assign-batch", "archive-batch", "customer/restore?id=", "transfer/preview", "transfer/initiate", "transfer/page", "transfer/detail", "transfer/assignable", "transfer/assign", "rule/customer/dedup", "rule/customer/reminder", "check-name", "check-credit-code"] uniq = len({u.split(" ", 1)[1].split("?")[0] for u in api_urls if " " in u}) missing = [e for e in REQUIRED if not any(e in u for u in api_urls)] step("39 端点覆盖广度(唯一路径≥50+矩阵清单%d条)" % len(REQUIRED), uniq >= 50 and not missing, "唯一路径=%s;缺失=%s" % (uniq, missing)) swept = sweep() left = dbq("SELECT COUNT(*) AS n FROM customer WHERE customer_name LIKE %s AND deleted=0", (PFX + "%",))[0]["n"] d_end = get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {} r_end = get_json(f"{BASE}/api/rule/customer/reminder", tok).get("data") or {} form_end = (get_json(f"{BASE}/api/preference/view-form/get?scopeKey=customer.overview", tok) or {}).get("data") step("40 夹具正门清扫+规则/形态偏好还原核对", left == 0 and d_end.get("similarityThreshold") == thr0 and r_end.get("firstTriggerDays") == f0 and form_end in ("list", None, ""), "清扫=%s行;残留=%s;dedup=%s(原%s);reminder=%s(原%s);overview形态=%s" % (swept, left, d_end.get("similarityThreshold"), thr0, r_end.get("firstTriggerDays"), f0, form_end)) shot(pg, "s40-final.png") browser.close() # ================= 报告落盘 ================= passed = sum(1 for s in steps if s["ok"]) failed = len(steps) - passed dur = round(time.time() - t0, 1) report = { "summary": {"total": len(steps), "passed": passed, "failed": failed, "passRate": ("%d%%" % round(passed * 100 / len(steps))) if steps else "0%", "startedAt": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t0)), "durationS": dur, "verdict": "PASS" if failed == 0 else "FAIL", "verdictRule": "全部步骤 ok 即 PASS,任一 FAIL 即 FAIL"}, "steps": steps, "errors": console_errors, "http_fails": http_fails, "api_urls": api_urls} rp = os.path.join(DEMO, "smoke-report.json") with open(rp, "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=1) print("\n==== summary ==== %d steps, %d failed (%.1fs) → %s" % (len(steps), failed, dur, rp)) for s in steps: if not s["ok"]: print(" FAIL %s | %s" % (s["step"], s["note"][:200])) print("ALL_PASS: %s" % (failed == 0)) if __name__ == "__main__": main()