You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

249 lines
12 KiB

# -*- 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)