# -*- coding: utf-8 -*- """票 17 复测 · P1 三条(D-01 / D-02 / D-03) D-01: 关闭项目后看板 stage0 卡无「冲突解除」死入口;冲突待处理 badge 不计已关闭项目(新建→关闭→看 badge 变化)。 D-02: 普通销售(99021003)对他人项目 冲突解除/推进/关闭/标记赢输 全被 68010 拒;负责人(99021002)对自己项目照常可操作。 D-03: 标记赢单弹窗带快照四字段(开标时间/地点/中标方式/中标金额),提交后开标页签回显 + 中标时间; 输单路径仍可用且不需要输单复盘字段(spin-out 维持)。 产物: retest-p1-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 step(name, ok, note=""): R.append({"step": name, "ok": bool(ok), "note": str(note)[:400]}) print(("PASS " if ok else "FAIL ") + name + ((" | " + str(note)) if note else "")) # ---------------- API helpers(与票 16 probe 同款) ---------------- 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) def wait_detail_id(page, tries=20): """建项成功后等详情 DTO(页面全局 D)加载完成,返回项目 id 字符串。""" for _ in range(tries): v = page.evaluate("() => (typeof D !== 'undefined' && D && D.id) ? String(D.id) : null") if v: return v page.wait_for_timeout(400) raise RuntimeError("detail DTO (D) not loaded after create") # ================================================================ # D-01(Playwright UI + API 双证) # ================================================================ 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 board_snapshot(): page.click("nav.tabs button[data-tab='board']") page.wait_for_timeout(1200) badge = page.inner_text("#conflictCnt").strip() cards = page.evaluate("""() => [...document.querySelectorAll('#boardBox .bcol')].slice(0,1).flatMap(col => [...col.querySelectorAll('.bcard')].map(c => ({ name: c.querySelector('.bname').textContent, hasBtn: !!c.querySelector('.bops'), btn: c.querySelector('.bops button') ? c.querySelector('.bops button').textContent : null})) )""") return badge, cards badge0, _ = board_snapshot() step("D-01.0 复测前 badge 基线(含 stage0 卡清单)", badge0.isdigit(), "badge=" + badge0) # 新建项目(此时为进行中 stage0,应被 badge 计入) name = f"R17-D01-关闭-{TS}" page.click("text=+ 新建项目") page.fill("#cName", name) page.fill("#cCustomer", CUSTOMER) page.fill("#cCard", CARD) page.click("#dlgCreate .dfoot .ok") page.wait_for_selector("text=建项目成功", timeout=15000) pid_d01 = wait_detail_id(page) page.wait_for_timeout(600) badge1, cards1 = board_snapshot() counted_active = any(c["name"] == name and c["hasBtn"] for c in cards1) step("D-01.1 进行中 stage0 项目被 badge 计入且卡带冲突解除入口", int(badge1) == int(badge0) + 1 and counted_active, f"badge {badge0}->{badge1}; card={name} hasBtn={counted_active}") # 直接关闭(stage0 / status=2)——先经应用自身路径切回详情页签 page.evaluate("id => openProject(id)", pid_d01) page.wait_for_selector("#detailHead .badge", timeout=10000) page.click("#detailActions button:has-text('关闭…')") page.select_option("#closeType", "CUSTOMER_CANCEL") page.fill("#closeReason", "复测D-01:建后即关") page.click("#dlgCloseProj .dfoot .ok") page.wait_for_selector("text=项目已关闭", timeout=15000) page.wait_for_timeout(600) badge2, cards2 = board_snapshot() closed_card = next((c for c in cards2 if c["name"] == name), None) step("D-01.2 关闭后 badge 回落(不计已关闭项目)", badge2 == badge0, f"badge {badge1}->{badge2}(期望回到 {badge0})") step("D-01.3 已关闭项目 stage0 卡无「冲突解除」死入口", closed_card is not None and not closed_card["hasBtn"], f"card visible={closed_card is not None}, hasBtn={closed_card['hasBtn'] if closed_card else 'N/A'}") page.screenshot(path=str(SHOTS / f"r17-d01-board-after-close.png"), full_page=True) # API 双证:stage0 卡 projectStatus=2 → conflictActionAvailable=false;badge 口径 = stage0 ∩ 进行中 b = _req("GET", "/api/project/board?scope=manage", tok=TOKD)["data"] st0 = b["columns"][0] api_closed = [c for c in st0["cards"] if str(c.get("projectStatus")) == "2"] api_active = [c for c in st0["cards"] if str(c.get("projectStatus")) == "1"] badge_api = str(b.get("conflictPendingCount")) step("D-01.4 API:已关闭卡 conflictActionAvailable=false", all(c.get("conflictActionAvailable") in (False, None, "false") for c in api_closed), f"closed_cards={len(api_closed)}, flags={[c.get('conflictActionAvailable') for c in api_closed][:5]}") step("D-01.5 API:badge = stage0 进行中数量(终态口径)", badge_api == str(len(api_active)), f"conflictPendingCount={badge_api}, stage0进行中={len(api_active)}, stage0已关闭={len(api_closed)}") # 死入口行为复核:对已关闭项目提交冲突解除仍应 68003 拒(服务端终态锁未松动) j = _req("POST", "/api/project/filing/review", {"id": pid_d01, "approved": "true"}, tok=TOKD) step("D-01.6 服务端终态锁保持:对已关闭项目冲突解除仍 68003 拒", j.get("code") == 68003, f"code={j.get('code')} msg={j.get('message')}") # ================================================================ # D-03(Playwright UI:赢单快照 + 开标回显;输单免复盘字段) # ================================================================ def create_and_to_stage5(prefix): page.click("text=+ 新建项目") page.fill("#cName", f"{prefix}-{TS}") page.fill("#cCustomer", CUSTOMER) page.fill("#cCard", CARD) page.click("#dlgCreate .dfoot .ok") page.wait_for_selector("text=建项目成功", timeout=15000) page.wait_for_timeout(700) page.click("#detailActions button:has-text('冲突解除')") page.select_option("#filingApproved", "true") page.fill("#filingRemark", "复测") page.click("#dlgFiling .dfoot .ok") page.wait_for_selector("text=报备审核回填成功", timeout=15000) page.wait_for_timeout(400) page.click("#detailActions button:has-text('推进(方案设计')") page.wait_for_timeout(500) page.click("#detailActions button:has-text('推进(商务报价')") page.wait_for_timeout(500) page.click("#detailActions button:has-text('无需招投标')") page.wait_for_selector("text=推进成功", timeout=15000) page.wait_for_timeout(400) create_and_to_stage5("R17-D03-赢单") pid_d03w = wait_detail_id(page) page.click("#detailActions button:has-text('标记赢单')") page.wait_for_selector("#dlgBid[open]", timeout=10000) dlg_fields = page.evaluate("""() => ({ hasBidResultSel: !!document.querySelector('#bidResultSel'), hasOpenTime: !!document.querySelector('#bidOpenTime'), hasOpenPlace: !!document.querySelector('#bidOpenPlace'), hasMethod: !!document.querySelector('#bidAwardMethod'), hasAmount: !!document.querySelector('#bidAwardAmount'), loseReviewFields: [...document.querySelectorAll('#dlgBid input, #dlgBid select')] .map(e => e.id).filter(id => /lose|competitor|fail|reason/i.test(id))})""") step("D-03.1 赢单弹窗带快照四字段(开标时间/地点/中标方式/中标金额)", all([dlg_fields["hasOpenTime"], dlg_fields["hasOpenPlace"], dlg_fields["hasMethod"], dlg_fields["hasAmount"]]), str(dlg_fields)) page.screenshot(path=str(SHOTS / "r17-d03-win-dialog.png"), full_page=True) place, method, amount = f"南京市公共资源交易中心-R17", "公开招标", "980000" page.fill("#bidOpenTime", "2026-09-20 09:30:00") page.fill("#bidOpenPlace", place) page.fill("#bidAwardMethod", method) page.fill("#bidAwardAmount", amount) page.click("#dlgBid .dfoot .ok") page.wait_for_selector("text=标记成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("D-03.2 标记赢单成功并停 stage5 闭环", "赢单" in head, head[:120].replace("\n", " ")) page.click("#detailSubtabs button[data-sub='bidopen']") page.wait_for_timeout(900) bo_txt = page.inner_text("#subBox") lines = [l.strip() for l in bo_txt.split("\n") if l.strip()] def val_after(label): try: i = lines.index(label) return lines[i + 1] if i + 1 < len(lines) else "" except ValueError: return "" v_time, v_place, v_method, v_amount, v_bidtime = (val_after("开标时间"), val_after("开标地点"), val_after("中标方式"), val_after("中标金额"), val_after("中标时间")) step("D-03.3 开标页签回显快照四值", v_time == "2026-09-20 09:30:00" and v_place == place and v_method == method and v_amount == amount, f"时间={v_time} 地点={v_place} 方式={v_method} 金额={v_amount}") step("D-03.4 开标页签回显中标时间(服务端盖章,非 '-')", v_bidtime not in ("", "-", ""), f"中标时间={v_bidtime}") page.screenshot(path=str(SHOTS / "r17-d03-bidopen-win.png"), full_page=True) bo = _req("GET", "/api/project/bid-open?projectId=" + pid_d03w, tok=TOKD)["data"] try: amount_ok = abs(float(bo.get("bidAwardAmount")) - float(amount)) < 0.01 except (TypeError, ValueError): amount_ok = False step("D-03.5 API 双证:bid-open 快照字段已落列", str(bo.get("bidOpenPlace")) == place and str(bo.get("bidAwardMethod")) == method and amount_ok and bo.get("bidTime") and str(bo.get("bidResult")) in ("1", "WIN"), f"place={bo.get('bidOpenPlace')}, method={bo.get('bidAwardMethod')}, amount={bo.get('bidAwardAmount')}, bidTime={bo.get('bidTime')}, bidResult={bo.get('bidResult')}") # 输单路径:仍可用、不需要输单复盘字段 create_and_to_stage5("R17-D03-输单") page.click("#detailActions button:has-text('标记输单')") page.wait_for_selector("#dlgBid[open]", timeout=10000) lose_mode = page.evaluate("""() => ({ bidResultSel: document.querySelector('#bidResultSel') ? document.querySelector('#bidResultSel').value : null, requiredInputs: [...document.querySelectorAll('#dlgBid input')].filter(i => i.required || /\\*/.test(i.closest('label') ? i.closest('label').textContent : '')).map(i => i.id)})""") page.click("#dlgBid .dfoot .ok") # 不填任何复盘字段直接提交 page.wait_for_selector("text=标记成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("D-03.6 输单路径可用:免输单复盘字段直接提交 → stage6 结项", "结项" in head and "已结项" in head, f"dialog={lose_mode}; head={head[:100].replace(chr(10), ' ')}") page.screenshot(path=str(SHOTS / "r17-d03-lose-terminal.png"), full_page=True) browser.close() # ================================================================ # D-02(API 直调 · 与票 16 probe2 同款复现路径) # ================================================================ nm = f"R17-D02-他人项目-{TS}" j = _req("POST", "/api/project/create", {"projectName": nm, "customerId": CUSTOMER, "schemeCardId": CARD}, tok=TOKD) pidA = j["data"] step("D-02.0 总监建他人项目(销售将对其操作)", j.get("code") == 0, f"id={pidA}") j = _req("POST", "/api/project/filing/review", {"id": pidA, "approved": "true"}, tok=TOKS) step("D-02.1 普通销售对他人项目「冲突解除」被拒 68010", j.get("code") == 68010, f"code={j.get('code')} msg={j.get('message')}") j = _req("POST", "/api/project/stage/advance", {"id": pidA}, tok=TOKS) step("D-02.2 普通销售对他人项目「推进」被拒 68010", j.get("code") == 68010, f"code={j.get('code')} msg={j.get('message')}") j = _req("POST", "/api/project/close", {"id": pidA, "closeType": "MANUAL"}, tok=TOKS) step("D-02.3 普通销售对他人项目「关闭」被拒 68010", j.get("code") == 68010, f"code={j.get('code')} msg={j.get('message')}") j = _req("POST", "/api/project/bid-result/mark", {"id": pidA, "bidResult": "1"}, tok=TOKS) b0_code = j.get("code") step("D-02.4 普通销售对他人项目「标记赢输」被拒 68010(stage0 即拒,权限先于状态)", b0_code == 68010, f"code={b0_code} msg={j.get('message')}") # 推到 stage5 后再验一次标记(状态机合规位置上的权限拒绝) _req("POST", "/api/project/filing/review", {"id": pidA, "approved": "true"}, tok=TOKD) for _ in range(2): _req("POST", "/api/project/stage/advance", {"id": pidA}, tok=TOKD) _req("POST", "/api/project/stage/advance", {"id": pidA, "skipBidding": "true"}, tok=TOKD) d = _req("GET", "/api/project/detail?id=" + pidA, tok=TOKD)["data"] j = _req("POST", "/api/project/bid-result/mark", {"id": pidA, "bidResult": "1"}, tok=TOKS) step("D-02.5 普通销售对他人 stage5 项目「标记赢单」被拒 68010", j.get("code") == 68010 and str(d.get("projectStage")) == "5", f"stage={d.get('projectStage')} code={j.get('code')} msg={j.get('message')}") # 负责人对自己项目照常可操作(未误伤) j = _req("POST", "/api/project/bid-result/mark", {"id": pidA, "bidResult": "1"}, tok=TOKD) d = _req("GET", "/api/project/detail?id=" + pidA, tok=TOKD)["data"] step("D-02.6 负责人标记赈权限未误伤(code=0,stage5 闭环)", j.get("code") == 0 and str(d.get("projectStage")) == "5" and str(d.get("bidResult")) == "1", f"code={j.get('code')} stage={d.get('projectStage')} bid={d.get('bidResult')}") nm2 = f"R17-D02-负责人自操-{TS}" pidB = _req("POST", "/api/project/create", {"projectName": nm2, "customerId": CUSTOMER, "schemeCardId": CARD}, tok=TOKD)["data"] j = _req("POST", "/api/project/filing/review", {"id": pidB, "approved": "true"}, tok=TOKD) ok_filing = j.get("code") == 0 j = _req("POST", "/api/project/stage/advance", {"id": pidB}, tok=TOKD) ok_adv = j.get("code") == 0 j = _req("POST", "/api/project/close", {"id": pidB, "closeType": "OTHER", "closeReason": "复测D-02 未误伤"}, tok=TOKD) step("D-02.7 负责人 冲突解除/推进/关闭 对自己项目全部成功(未误伤)", ok_filing and ok_adv and j.get("code") == 0, f"filing={ok_filing} advance={ok_adv} close_code={j.get('code')}") # 普通销售作为自己项目的负责人:同样可操作(权限=负责人∨管理员∨总监 口径验证) pidS = _req("POST", "/api/project/create", {"projectName": f"R17-D02-销售自有-{TS}", "customerId": CUSTOMER, "schemeCardId": CARD}, tok=TOKS)["data"] j = _req("POST", "/api/project/filing/review", {"id": pidS, "approved": "true"}, tok=TOKS) step("D-02.8 普通销售对自己(负责人)项目可冲突解除——口径为负责人制非角色封禁", j.get("code") == 0, f"code={j.get('code')} msg={j.get('message')}") # ---------------- 汇总 ---------------- passed = sum(1 for r in R if r["ok"]) print(f"\n==== P1 复测 {passed}/{len(R)} PASS ====") (AUD / "retest-p1-result.json").write_text(json.dumps(R, ensure_ascii=False, indent=1), encoding="utf-8") sys.exit(0 if passed == len(R) else 1)