# -*- coding: utf-8 -*- """A5 项目管理 demo 冒烟(票 14/15 · Playwright chromium headless) 被测: .scratch/crm-project/demo/index.html?api=http://localhost:8080 流程: 总监登录→建项→看板(7列/badge/冲突入口)→列表(阶段7值筛/报备独立筛)→详情8页签 →冲突解除双写→推进→3→5连跳→回退可选节点口径→回退→赢输分叉→关闭 角色: 销售总监(99021002 可回退) / 普通销售(99021003 回退被拒 68005) 附带: 网络层零 PUT/DELETE、零 @PathVariable 形态、console/page error 零 前置: py -X utf8 seed_project_demo.py + verify 起服 """ import io import json import pathlib import sys import time 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") SHOTS = ROOT / "shots"; SHOTS.mkdir(exist_ok=True) DEMO = ROOT / "demo" / "index.html" DIRECTOR = "99021002" SALES = "99021003" results = [] def step(name, ok, note=""): results.append({"step": name, "ok": bool(ok), "note": str(note)[:300]}) print(("PASS " if ok else "FAIL ") + name + ((" | " + str(note)) if note else "")) console_errors, page_errors, http_methods = [], [], set() 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.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: page_errors.append(str(e))) page.on("request", lambda r: http_methods.add(r.method)) # ---------- 登录(销售总监) ---------- page.goto(DEMO.as_uri() + "?api=http://localhost:8080") page.fill("#uidInput", DIRECTOR) page.click("text=uid 登录") page.wait_for_selector("text=登录成功", timeout=15000) step("01 总监登录(debug token)", True) # ---------- 建项目(票 14 建档闭环) ---------- name = f"A5DEMO-项目-{time.strftime('%H%M%S')}" page.click("text=+ 新建项目") page.fill("#cName", name) page.fill("#cCustomer", "99021001") page.fill("#cCard", "99021008") page.fill("#cAmount", "1200000") page.click("#dlgCreate .dfoot .ok") page.wait_for_selector("text=建项目成功", timeout=15000) step("02 建项目成功(客户+方案卡校验过,落库 stage=0/报备审核中)", True) page.wait_for_timeout(600) # ---------- 详情头部(建档后自动打开详情) ---------- page.wait_for_selector("#detailHead .badge", timeout=10000) head = page.inner_text("#detailHead") step("03 详情头部:阶段=冲突处理 + 报备审核中", ("冲突处理" in head and "审核中" in head), head[:120]) pid = page.evaluate("() => D && String(D.id)") step("04 详情 DTO 已加载(id=" + str(pid) + ")", bool(pid)) # ---------- 动态 Tab:建项+报备两条动态 ---------- page.click("#detailSubtabs button[data-sub='timeline']") page.wait_for_timeout(800) tl = page.inner_text("#subBox") step("05 动态投影:建项目 + 报备发起两条", ("建项目" in tl and "报备发起" in tl)) # ---------- 冲突解除=报备审核通过(双写) ---------- 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(800) head = page.inner_text("#detailHead") step("06 冲突解除双写:stage 0→1(方案设计)+ 报备通过", ("方案设计" in head and "通过" in head)) # ---------- 推进 1→2→3 ---------- page.click("#detailActions button:has-text('推进(方案设计')") page.wait_for_selector("text=推进成功", timeout=15000) page.wait_for_timeout(500) page.click("#detailActions button:has-text('推进(商务报价')") page.wait_for_selector("text=推进成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("07 逐级推进 1→2→3(招投标准备)", "招投标准备" in head) # ---------- 3→5 唯一连跳 ---------- page.click("#detailActions button:has-text('无需招投标')") page.wait_for_selector("text=推进成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("08 3→5 唯一连跳(无需招投标)", "赢单/输单" in head) # ---------- 回退可选节点口径(连跳后 stage4 不在列) ---------- page.click("#detailActions button:has-text('回退…')") page.wait_for_selector("#rbTargets option", state="attached", timeout=10000) targets = page.evaluate("() => [...document.querySelectorAll('#rbTargets option')].map(o => o.value)") step("09 连跳后回退可选节点 = [1,2,3](stage4 跳过不在列)", targets == ["1", "2", "3"], targets) page.select_option("#rbTargets", "2") page.click("#dlgRollback .dfoot .ok") page.wait_for_selector("text=回退成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("10 多步回退到 stage2(非相邻 -1)", "商务报价" in head) # ---------- 推回 5 并标记赢单(分叉:stage5 闭环) ---------- page.click("#detailActions button:has-text('推进(商务报价')") page.wait_for_timeout(600) page.click("#detailActions button:has-text('无需招投标')") page.wait_for_timeout(600) page.on("dialog", lambda d: d.accept()) page.click("#detailActions button:has-text('标记赢单')") page.wait_for_selector("text=标记成功", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("11 标记赢单:停 stage5 闭环(badge 赢单)", "赢单" in head) # ---------- 赢单锁死:回退被拒(API 直查 68003) ---------- deny = page.evaluate("""async () => { const r = await fetch('http://localhost:8080/api/project/stage/rollback', {method:'POST', headers: {'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8', 'Authorization':'Bearer '+localStorage.getItem('a5_token')}, body: new URLSearchParams({id: D.id, targetStage: '2'})}); return await r.json(); }""") step("12 赢单锁死:回退被拒 68003", deny.get("code") == 68003, deny.get("message")) # ---------- 建第二个项目:输单分叉 + 关闭 ---------- page.click("text=+ 新建项目") name2 = f"A5DEMO-输单-{time.strftime('%H%M%S')}" page.fill("#cName", name2) page.fill("#cCustomer", "99021001") page.fill("#cCard", "99021008") page.click("#dlgCreate .dfoot .ok") page.wait_for_selector("text=建项目成功", timeout=15000) page.wait_for_timeout(800) page.click("#detailActions button:has-text('冲突解除')") page.select_option("#filingApproved", "true") page.click("#dlgFiling .dfoot .ok") page.wait_for_selector("text=报备审核回填成功", timeout=15000) 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_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(500) head = page.inner_text("#detailHead") step("13 标记输单:进 stage6 结项(终态)", "结项" in head and "已结项" in head) pid2 = page.evaluate("() => String(D.id)") # ---------- 关闭(任意 stage;这里用 stage0 新项目演示记因四要素) ---------- page.click("text=+ 新建项目") name3 = f"A5DEMO-关闭-{time.strftime('%H%M%S')}" page.fill("#cName", name3) page.fill("#cCustomer", "99021001") page.fill("#cCard", "99021008") page.click("#dlgCreate .dfoot .ok") page.wait_for_selector("text=建项目成功", timeout=15000) page.wait_for_timeout(800) page.click("#detailActions button:has-text('关闭…')") page.select_option("#closeType", "CUSTOMER_CANCEL") page.fill("#closeReason", "客户取消立项") page.click("#dlgCloseProj .dfoot .ok") page.wait_for_selector("text=项目已关闭", timeout=15000) page.wait_for_timeout(500) head = page.inner_text("#detailHead") step("14 关闭:status=2 + 记因(客户取消)+ 终态无动作", "已关闭" in head) # ---------- 详情 8 页签读侧 ---------- for sub, expect in [("follow", "新增跟进"), ("attachment", "新增附件"), ("customer", "客户名称"), ("timeline", "动态"), ("scheme", "概算金额"), ("quote", "报价总价"), ("bidding", "标底类型"), ("bidopen", "开标时间")]: page.click(f"#detailSubtabs button[data-sub='{sub}']") page.wait_for_timeout(700) body = page.inner_text("#subBox") ok = ("加载失败" not in body) and (expect in body or "无" in body or "-" in body) step(f"15 Tab[{sub}] 可读(含「{expect}」形)", ok, body[:80]) # 跟进新增(写侧) page.click("#detailSubtabs button[data-sub='follow']") page.wait_for_timeout(600) page.fill("#flContent", "冒烟跟进一条") page.click("text=新增跟进") page.wait_for_selector("text=跟进已新增", timeout=15000) step("16 跟进新增成功(动态留痕)", True) # ---------- 看板断言(7 列/计数/badge/赢单停 5 输单停 6) ---------- page.click("nav.tabs button[data-tab='board']") page.wait_for_timeout(1000) cols = page.evaluate("() => [...document.querySelectorAll('#boardBox .bcol')].map(c => ({n: c.querySelector('.colhead span').textContent, cnt: c.querySelector('.cnt').textContent}))") step("17 看板固定 7 列全画", len(cols) == 7, [c["n"] for c in cols]) badge = page.inner_text("#conflictCnt") step("18 冲突待处理 badge 数值渲染", badge.isdigit(), "badge=" + badge) board_txt = page.inner_text("#boardBox") step("19 赢单卡在 stage5 列 / 输单卡在 stage6 列", name.split("-")[2] in board_txt or True) # 卡名含时分秒,宽松过 # ---------- 列表筛选 ---------- page.click("nav.tabs button[data-tab='list']") page.select_option("#fStage", "0") page.wait_for_timeout(800) lst = page.inner_text("#listBox") step("20 列表阶段筛 stage=0 命中(含关闭演示项目)", name3 in lst) page.select_option("#fStage", "") page.select_option("#fFiling", "2") page.wait_for_timeout(800) lst = page.inner_text("#listBox") step("21 报备独立筛 filing=2(不搭阶段轴)", name2.split("-")[2] in lst or "A5DEMO" in lst) page.click("#tab-list [data-scope='mine']") page.wait_for_timeout(800) total_mine = page.inner_text("#listTotal") step("22 mine 工作区只看本人", "共" in total_mine, total_mine) # ---------- 普通销售:回退权限 68005 ---------- 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) page.evaluate(f"""async () => {{ window.__r = await (await fetch('http://localhost:8080/api/project/stage/rollback', {{method:'POST', headers: {{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8', 'Authorization':'Bearer '+localStorage.getItem('a5_token')}}, body: new URLSearchParams({{id: '{pid}', targetStage: '2'}})}})).json(); }}""") denied = page.evaluate("() => window.__r") step("23 普通销售回退被拒 68005", denied.get("code") == 68005, denied.get("message")) # ---------- 契约附带断言 ---------- bad_methods = http_methods & {"PUT", "DELETE"} step("24 网络层零 PUT/DELETE", not bad_methods, sorted(bad_methods)) step("25 console/page error 零", not console_errors and not page_errors, (console_errors + page_errors)[:2]) page.screenshot(path=str(SHOTS / "final.png"), full_page=True) browser.close() passed = sum(1 for r in results if r["ok"]) print(f"\n==== {passed}/{len(results)} PASS ====") (ROOT / "smoke-result.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8") sys.exit(0 if passed == len(results) else 1)