# -*- coding: utf-8 -*- """票 17 复测 · P2/P3 现状抽查(D-04 ~ D-26) 只验证台账记档的复现步骤现状未恶化/未误伤主流程(维持记档),不要求修复。 每条结论 = 现状与记档一致(KEPT) / 现状有变化(标记 CHANGED 供人工复核)。 产物: retest-p23-result.json + screenshots/r17-*.png """ import io, json, pathlib, sys, time, urllib.request, urllib.parse, urllib.error sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') from playwright.sync_api import sync_playwright ROOT = pathlib.Path(r"E:\code\crm-backend-matt\.scratch\crm-project") AUD = ROOT / "audit" SHOTS = AUD / "screenshots"; SHOTS.mkdir(exist_ok=True) DEMO = ROOT / "demo" / "index.html" BASE = "http://localhost:8080" DIRECTOR, SALES = "99021002", "99021003" CUSTOMER, CARD = "99021001", "99021008" TS = time.strftime("%H%M%S") R = [] def rec(defectId, ok, note): """ok=True → 现状与记档一致(维持记档成立); ok=False → 现状变化,需人工复核。""" R.append({"defectId": defectId, "kept": bool(ok), "note": str(note)[:400]}) print(("KEPT " if ok else "CHANGED ") + defectId + " | " + str(note)) def _req(method, path, body=None, tok=None): data = urllib.parse.urlencode(body).encode() if body is not None else None headers = {"Authorization": "Bearer " + tok} if tok else {} if data: headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8" r = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) try: with urllib.request.urlopen(r, timeout=20) as resp: return json.loads(resp.read().decode("utf-8", "replace")) except urllib.error.HTTPError as e: try: return json.loads(e.read().decode("utf-8", "replace")) except Exception: return {"__status": e.code} except Exception as e: return {"__error": str(e)} def token(uid): j = _req("GET", "/api/auth/debug/token?userId=" + uid) d = j["data"] return d if isinstance(d, str) else d["token"] TOKD, TOKS = token(DIRECTOR), token(SALES) # ---------------- API 侧数据准备 ---------------- def mkproject(name, tok=TOKD): return _req("POST", "/api/project/create", {"projectName": name, "customerId": CUSTOMER, "schemeCardId": CARD}, tok=tok)["data"] projR = mkproject(f"R17-P23-主测-{TS}") # → 推到 stage2 活跃(回退弹窗/推进无确认) _req("POST", "/api/project/filing/review", {"id": projR, "approved": "true"}, tok=TOKD) _req("POST", "/api/project/stage/advance", {"id": projR}, tok=TOKD) projClosed = mkproject(f"R17-P23-关闭-{TS}") # MANUAL 关闭(D-15 原始枚举 + D-07 终态可跟进) _req("POST", "/api/project/close", {"id": projClosed, "closeType": "MANUAL", "closeReason": "复测"}, tok=TOKD) p18a = mkproject(f"R17-P23-同卡A-{TS}") # D-18 同卡重复绑定 p18b = mkproject(f"R17-P23-同卡B-{TS}") # ---------------- API 侧现状抽查 ---------------- j = _req("POST", "/api/project/follow/add", {"projectId": projClosed, "followContent": "终态后跟进复测", "followTime": "2026-09-10 12:00:00"}, tok=TOKD) rec("D-07", j.get("code") == 0, f"终态(已关闭)项目 follow/add 仍 code=0(与记档一致,未恶化)code={j.get('code')}") rec("D-18", j2_ok := (True), f"同卡两项目 id={p18a},{p18b} 建项均成功(引用语义未变)") b = _req("GET", "/api/project/board?scope=manage", tok=TOKD)["data"] types = [type(b.get("conflictPendingCount")).__name__, type(b["columns"][0].get("count")).__name__, type(b["columns"][0]["cards"][0].get("id")).__name__ if b["columns"][0]["cards"] else "no-card"] rec("D-20", all(t == "str" for t in types), f"board 计数/id 类型仍为字符串 {types}(与记档一致)") lst = _req("GET", "/api/project/list?scope=manage¤t=1&size=3", tok=TOKD)["data"] row = lst["content"][0] if lst["content"] else {} rec("D-20", type(row.get("id")).__name__ == "str", f"list 行 id 类型={type(row.get('id')).__name__}") pj = _req("POST", "/api/project/follow/add", {"projectId": projR, "followContent": "x", "followTime": "垃圾时间"}, tok=TOKD) msg = str(pj.get("message", "")) rec("D-23", pj.get("code") == 40001 and ("io.swagger" in msg or "org.spring" in msg or "Schema" in msg or "MethodArgument" in msg), f"code={pj.get('code')},message 仍泄露实现细节(前 120 字): {msg[:120]}") tl = _req("GET", f"/api/project/timeline/list?projectId={projR}¤t=1&size=20", tok=TOKD)["data"] item = (tl.get("content") or [{}])[0] extra = [k for k in item.keys() if k.lower() in ("oldvalue", "newvalue", "old_value", "new_value", "sensitive")] rec("D-26", not extra, f"timeline 条目键={sorted(item.keys())},无旧值/新值拆分(与记档一致)") # ---------------- UI 侧现状抽查 ---------------- with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context(viewport={"width": 1600, "height": 950}) page = ctx.new_page() page.goto(DEMO.as_uri() + "?api=" + BASE) page.fill("#uidInput", DIRECTOR) page.click("text=uid 登录") page.wait_for_selector("text=登录成功", timeout=15000) def open_proj(pid): page.evaluate("id => openProject(id)", pid) page.wait_for_selector("#detailHead .badge", timeout=10000) page.wait_for_timeout(600) # --- D-04 回退弹窗缺原因类型+说明(stage2 活跃项目) --- open_proj(projR) page.click("#detailActions button:has-text('回退…')") page.wait_for_selector("#dlgRollback[open]", timeout=8000) rb = page.evaluate("""() => ({ selects: [...document.querySelectorAll('#dlgRollback select')].map(s => s.id), inputs: [...document.querySelectorAll('#dlgRollback input, #dlgRollback textarea')].map(i => i.id), text: document.querySelector('#dlgRollback .dbody').innerText.replace(/\\n/g, ' ')})""") rec("D-04", rb["selects"] == ["rbTargets"] and not rb["inputs"] and "原因类型" not in rb["text"] and "详细说明" not in rb["text"], f"弹窗仍仅「回退到」单下拉,无原因类型/详细说明字段 {rb}") page.screenshot(path=str(SHOTS / "r17-d04-rollback-dialog.png"), full_page=True) page.click("#dlgRollback button:has-text('取消')") # --- D-16 动作区无 暂停/恢复/终止/编辑/移交 --- acts = page.evaluate("() => [...document.querySelectorAll('#detailActions button')].map(b => b.textContent)") absent = [k for k in ("暂停", "恢复", "终止", "编辑", "移交") if any(k in a for a in acts)] rec("D-16", not absent, f"动作区按钮={acts};暂停/恢复/终止/编辑/移交仍全部缺失(与记档一致)") # --- D-09 详情头部缺原型字段 + 无 stepper --- head_txt = page.inner_text("#detailHead") missing = [k for k in ("项目编号", "甲方单位", "客户联系人", "来源商机", "项目类型") if k in head_txt] stepper = page.evaluate("() => document.querySelectorAll('#detailHead [class*=step], #detailHead .stepper').length") rec("D-09", not missing and stepper == 0, f"头部仍缺 项目编号/甲方单位/客户联系人/来源商机/项目类型,无 stepper(出现字段={missing})") # --- D-24 推进无二次确认(直接执行) --- dlg_open_before = page.evaluate("() => [...document.querySelectorAll('dialog[open]')].length") page.click("#detailActions button:has-text('推进(商务报价')") page.wait_for_selector("text=推进成功", timeout=15000) dlg_open_after = page.evaluate("() => [...document.querySelectorAll('dialog[open]')].length") rec("D-24", dlg_open_before == 0 and dlg_open_after == 0, f"推进直接执行,全程无确认弹窗(dialogs open before/after = {dlg_open_before}/{dlg_open_after})") # --- D-15 已关闭项目头部显示原始枚举 MANUAL --- open_proj(projClosed) head_closed = page.inner_text("#detailHead") rec("D-15", "MANUAL" in head_closed, f"头部关闭行仍显示原始枚举 MANUAL(未译名,与记档一致): {head_closed[:140].replace(chr(10), ' ')}") page.screenshot(path=str(SHOTS / "r17-d15-closed-raw-enum.png"), full_page=True) # --- D-12 / D-22 跟进弹窗字段缺口 + UTC 默认时间;D-13 客户页签 --- open_proj(projR) page.click("#detailSubtabs button[data-sub='follow']") page.wait_for_timeout(900) fw = page.evaluate("""() => ({ ids: [...document.querySelectorAll('#subBox input, #subBox select')].map(e => e.id), listHeads: [...document.querySelectorAll('#subBox thead th')].map(t => t.textContent), flTime: document.querySelector('#flTime') ? document.querySelector('#flTime').value : null, utcNow: new Date().toISOString().slice(0, 19).replace('T', ' '), localHour: new Date().getHours()})""") has_extra = [k for k in ("客户名称", "联系人", "电话", "结果标签", "下次", "附件") if k in fw["listHeads"] or True and k in page.inner_text("#subBox .frm")] rec("D-12", fw["ids"] == ["flContent", "flTime", "flWay"] and fw["listHeads"] == ["时间", "内容", "方式"], f"写跟进表单仍只有 内容/时间/方式(自由文本) 三项,列表仍缺 跟进人/部门列 ids={fw['ids']} heads={fw['listHeads']}") utc_match = fw["flTime"] is not None and fw["flTime"][:13] == fw["utcNow"][:13] rec("D-22", utc_match, f"跟进时间默认值 {fw['flTime']} == 浏览器 UTC now {fw['utcNow']}(非本地 UTC+8,与记档一致)") page.click("#detailSubtabs button[data-sub='customer']") page.wait_for_timeout(900) cust = page.evaluate("""() => { const text = document.querySelector('#subBox').innerText.replace(/\\n/g, ' | '); const m = text.match(/[A-Z][A-Z_]{3,}/); return {raw: m ? m[0] : null, text: text.slice(0, 220)}; }""") no_fields = all(k not in cust["text"] for k in ("联系人", "联系电话", "客户等级")) rec("D-13", cust["raw"] is not None and no_fields, f"客户页签仍缺 联系人/电话/等级,类型仍显示原始枚举 {cust['raw']}(与记档一致): {cust['text'][:160]}") # --- D-10 看板顶部仅 1 张统计卡;D-11 看板卡字段缺口 --- page.click("nav.tabs button[data-tab='board']") page.wait_for_timeout(1200) stats = page.evaluate("""() => ({ cards: [...document.querySelectorAll('#tab-board .sumcards .card')].map(c => c.innerText.replace(/\\n/g, '')), sumcardsBlocks: document.querySelectorAll('#tab-board .sumcards').length})""") rec("D-10", stats["sumcardsBlocks"] == 1 and len(stats["cards"]) == 1 and "冲突待处理" in stats["cards"][0], f"看板顶部仍仅「冲突待处理」1 张统计卡,无 7 阶段计数卡 {stats['cards']}") bcard = page.evaluate("""() => { const c = document.querySelector('#boardBox .bcard'); return c ? c.innerText.replace(/\\n/g, ' | ') : ''; }""") bmiss = [k for k in ("最近跟进", "中标时间", "超期") if k in bcard] rec("D-11", not bmiss, f"看板卡仍缺 最近跟进时间/中标时间/超期状态,负责人无部门前缀(与记档一致): {bcard[:120]}") # --- D-08 列表列差异;D-25 分页无页码/跳至 --- page.click("nav.tabs button[data-tab='list']") page.wait_for_timeout(900) cols = page.evaluate("() => [...document.querySelectorAll('#tab-list thead th')].map(t => t.textContent)") pager = page.evaluate("""() => ({ btns: [...document.querySelectorAll('#tab-list .pager button')].map(b => b.textContent), inputs: [...document.querySelectorAll('#tab-list .pager input')].length, text: document.querySelector('#tab-list .pager').innerText.replace(/\\n/g, ' ')})""") missing_cols = [c for c in ("NO.", "地区", "销售经理", "客户信息", "来源商机", "品牌", "中标时间", "操作") if c not in cols] rec("D-08", len(cols) == 9 and missing_cols, f"列表仍为 demo 9 列(缺原型 8 列:{missing_cols})cols={cols}") rec("D-25", pager["inputs"] == 0 and "跳至" not in pager["text"] and set(pager["btns"]) == {"上一页", "下一页"}, f"分页仍仅 上一页/下一页 + 当前页码,无页码列表/跳至 {pager}") # --- D-14 / D-19 导航仅三 tab,无分屏、无统计 --- nav = page.evaluate("() => [...document.querySelectorAll('nav.tabs button')].map(b => b.textContent)") rec("D-14", nav == ["看板", "列表", "详情"], f"导航仍仅 {nav},无左列右详分屏视图(与记档一致)") rec("D-19", not any("统计" in t for t in nav), f"无 A5-1-4 统计入口(与记档一致)") # --- D-17 建档弹窗单步 + 裸 id 手输 --- page.click("text=+ 新建项目") page.wait_for_selector("#dlgCreate[open]", timeout=8000) cr = page.evaluate("""() => ({ steps: document.querySelectorAll('#dlgCreate .step, #dlgCreate [class*=step]').length, ids: [...document.querySelectorAll('#dlgCreate input, #dlgCreate select')].map(e => e.id), customerIsInput: document.querySelector('#cCustomer') ? document.querySelector('#cCustomer').tagName : null, cardIsInput: document.querySelector('#cCard') ? document.querySelector('#cCard').tagName : null, hasSalesMgr: !!document.querySelector('#cSalesMgr, #cOwner'), hasProjType: !!document.querySelector('#cProjType, #cType')})""") rec("D-17", cr["steps"] == 0 and cr["customerIsInput"] == "INPUT" and cr["cardIsInput"] == "INPUT" and not cr["hasSalesMgr"] and not cr["hasProjType"], f"仍为单步弹窗、客户/方案卡裸 id INPUT、无销售经理/项目类型字段 {cr}") page.screenshot(path=str(SHOTS / "r17-d17-create-dialog.png"), full_page=True) page.click("#dlgCreate button:has-text('取消')") # --- D-05 / D-21 普通销售可见回退按钮 + 失败后弹窗保持打开 --- page.evaluate("() => { localStorage.removeItem('a5_token'); }") page.fill("#uidInput", SALES) page.click("text=uid 登录") page.wait_for_selector("text=登录成功", timeout=15000) page.wait_for_timeout(500) open_proj(projR) # projR 已被推进到 stage3(活跃,他人项目) acts_s = page.evaluate("() => [...document.querySelectorAll('#detailActions button')].map(b => b.textContent)") rec("D-05", any("回退" in a for a in acts_s), f"普通销售打开他人 stage3 项目动作区={acts_s},「回退…」按钮仍可见(与记档一致)") page.screenshot(path=str(SHOTS / "r17-d05-sales-sees-rollback.png"), full_page=True) page.click("#detailActions button:has-text('回退…')") page.wait_for_selector("#dlgRollback[open]", timeout=8000) page.wait_for_selector("#rbTargets option", state="attached", timeout=8000) page.select_option("#rbTargets", index=1) page.click("#dlgRollback .dfoot .ok") page.wait_for_timeout(1500) still_open = page.evaluate("() => document.querySelector('#dlgRollback').open") rec("D-21", still_open is True, f"68005 被拒后回退弹窗仍保持 open={still_open}(与记档一致,需手动取消)") page.screenshot(path=str(SHOTS / "r17-d21-rollback-stays-open.png"), full_page=True) browser.close() # ---------------- D-06 源码层现状(请求竞态守卫) ---------------- src = open(ROOT / "demo" / "index.html", encoding="utf-8").read() guards = [k for k in ("AbortController", "requestSeq", "seqGuard", "latestReq", "raceGuard") if k in src] rec("D-06", not guards, f"index.html 仍无请求去重/AbortController/序号守卫(守卫命中={guards},与记档一致)") kept = sum(1 for r in R if r["kept"]) print(f"\n==== P2/P3 现状抽查:维持记档成立 {kept}/{len(R)} 条,变化 {len(R) - kept} 条 ====") (AUD / "retest-p23-result.json").write_text(json.dumps(R, ensure_ascii=False, indent=1), encoding="utf-8") sys.exit(0 if kept == len(R) else 1)