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
297 lines
17 KiB
|
19 hours ago
|
# -*- 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)
|