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.

297 lines
17 KiB

# -*- 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 "<label缺失>"
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 ("", "-", "<label缺失>"), 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)