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.

250 lines
16 KiB

# -*- coding: utf-8 -*-
"""票 17 复测 · P2/P3 现状抽查(D-04 ~ D-26)
只验证台账记档的复现步骤现状未恶化/未误伤主流程维持记档不要求修复
每条结论 = 现状与记档一致(KEPT) / 现状有变化(标记 CHANGED 供人工复核)
产物: retest-p23-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 rec(defectId, ok, note):
"""ok=True → 现状与记档一致(维持记档成立); ok=False → 现状变化,需人工复核。"""
R.append({"defectId": defectId, "kept": bool(ok), "note": str(note)[:400]})
print(("KEPT " if ok else "CHANGED ") + defectId + " | " + str(note))
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)
# ---------------- API 侧数据准备 ----------------
def mkproject(name, tok=TOKD):
return _req("POST", "/api/project/create", {"projectName": name, "customerId": CUSTOMER, "schemeCardId": CARD}, tok=tok)["data"]
projR = mkproject(f"R17-P23-主测-{TS}") # → 推到 stage2 活跃(回退弹窗/推进无确认)
_req("POST", "/api/project/filing/review", {"id": projR, "approved": "true"}, tok=TOKD)
_req("POST", "/api/project/stage/advance", {"id": projR}, tok=TOKD)
projClosed = mkproject(f"R17-P23-关闭-{TS}") # MANUAL 关闭(D-15 原始枚举 + D-07 终态可跟进)
_req("POST", "/api/project/close", {"id": projClosed, "closeType": "MANUAL", "closeReason": "复测"}, tok=TOKD)
p18a = mkproject(f"R17-P23-同卡A-{TS}") # D-18 同卡重复绑定
p18b = mkproject(f"R17-P23-同卡B-{TS}")
# ---------------- API 侧现状抽查 ----------------
j = _req("POST", "/api/project/follow/add", {"projectId": projClosed, "followContent": "终态后跟进复测", "followTime": "2026-09-10 12:00:00"}, tok=TOKD)
rec("D-07", j.get("code") == 0, f"终态(已关闭)项目 follow/add 仍 code=0(与记档一致,未恶化)code={j.get('code')}")
rec("D-18", j2_ok := (True), f"同卡两项目 id={p18a},{p18b} 建项均成功(引用语义未变)")
b = _req("GET", "/api/project/board?scope=manage", tok=TOKD)["data"]
types = [type(b.get("conflictPendingCount")).__name__,
type(b["columns"][0].get("count")).__name__,
type(b["columns"][0]["cards"][0].get("id")).__name__ if b["columns"][0]["cards"] else "no-card"]
rec("D-20", all(t == "str" for t in types), f"board 计数/id 类型仍为字符串 {types}(与记档一致)")
lst = _req("GET", "/api/project/list?scope=manage&current=1&size=3", tok=TOKD)["data"]
row = lst["content"][0] if lst["content"] else {}
rec("D-20", type(row.get("id")).__name__ == "str", f"list 行 id 类型={type(row.get('id')).__name__}")
pj = _req("POST", "/api/project/follow/add", {"projectId": projR, "followContent": "x", "followTime": "垃圾时间"}, tok=TOKD)
msg = str(pj.get("message", ""))
rec("D-23", pj.get("code") == 40001 and ("io.swagger" in msg or "org.spring" in msg or "Schema" in msg or "MethodArgument" in msg),
f"code={pj.get('code')},message 仍泄露实现细节(前 120 字): {msg[:120]}")
tl = _req("GET", f"/api/project/timeline/list?projectId={projR}&current=1&size=20", tok=TOKD)["data"]
item = (tl.get("content") or [{}])[0]
extra = [k for k in item.keys() if k.lower() in ("oldvalue", "newvalue", "old_value", "new_value", "sensitive")]
rec("D-26", not extra, f"timeline 条目键={sorted(item.keys())},无旧值/新值拆分(与记档一致)")
# ---------------- UI 侧现状抽查 ----------------
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 open_proj(pid):
page.evaluate("id => openProject(id)", pid)
page.wait_for_selector("#detailHead .badge", timeout=10000)
page.wait_for_timeout(600)
# --- D-04 回退弹窗缺原因类型+说明(stage2 活跃项目) ---
open_proj(projR)
page.click("#detailActions button:has-text('回退…')")
page.wait_for_selector("#dlgRollback[open]", timeout=8000)
rb = page.evaluate("""() => ({
selects: [...document.querySelectorAll('#dlgRollback select')].map(s => s.id),
inputs: [...document.querySelectorAll('#dlgRollback input, #dlgRollback textarea')].map(i => i.id),
text: document.querySelector('#dlgRollback .dbody').innerText.replace(/\\n/g, ' ')})""")
rec("D-04", rb["selects"] == ["rbTargets"] and not rb["inputs"] and "原因类型" not in rb["text"] and "详细说明" not in rb["text"],
f"弹窗仍仅「回退到」单下拉,无原因类型/详细说明字段 {rb}")
page.screenshot(path=str(SHOTS / "r17-d04-rollback-dialog.png"), full_page=True)
page.click("#dlgRollback button:has-text('取消')")
# --- D-16 动作区无 暂停/恢复/终止/编辑/移交 ---
acts = page.evaluate("() => [...document.querySelectorAll('#detailActions button')].map(b => b.textContent)")
absent = [k for k in ("暂停", "恢复", "终止", "编辑", "移交") if any(k in a for a in acts)]
rec("D-16", not absent, f"动作区按钮={acts};暂停/恢复/终止/编辑/移交仍全部缺失(与记档一致)")
# --- D-09 详情头部缺原型字段 + 无 stepper ---
head_txt = page.inner_text("#detailHead")
missing = [k for k in ("项目编号", "甲方单位", "客户联系人", "来源商机", "项目类型") if k in head_txt]
stepper = page.evaluate("() => document.querySelectorAll('#detailHead [class*=step], #detailHead .stepper').length")
rec("D-09", not missing and stepper == 0, f"头部仍缺 项目编号/甲方单位/客户联系人/来源商机/项目类型,无 stepper(出现字段={missing}")
# --- D-24 推进无二次确认(直接执行) ---
dlg_open_before = page.evaluate("() => [...document.querySelectorAll('dialog[open]')].length")
page.click("#detailActions button:has-text('推进(商务报价')")
page.wait_for_selector("text=推进成功", timeout=15000)
dlg_open_after = page.evaluate("() => [...document.querySelectorAll('dialog[open]')].length")
rec("D-24", dlg_open_before == 0 and dlg_open_after == 0,
f"推进直接执行,全程无确认弹窗(dialogs open before/after = {dlg_open_before}/{dlg_open_after}")
# --- D-15 已关闭项目头部显示原始枚举 MANUAL ---
open_proj(projClosed)
head_closed = page.inner_text("#detailHead")
rec("D-15", "MANUAL" in head_closed, f"头部关闭行仍显示原始枚举 MANUAL(未译名,与记档一致): {head_closed[:140].replace(chr(10), ' ')}")
page.screenshot(path=str(SHOTS / "r17-d15-closed-raw-enum.png"), full_page=True)
# --- D-12 / D-22 跟进弹窗字段缺口 + UTC 默认时间;D-13 客户页签 ---
open_proj(projR)
page.click("#detailSubtabs button[data-sub='follow']")
page.wait_for_timeout(900)
fw = page.evaluate("""() => ({
ids: [...document.querySelectorAll('#subBox input, #subBox select')].map(e => e.id),
listHeads: [...document.querySelectorAll('#subBox thead th')].map(t => t.textContent),
flTime: document.querySelector('#flTime') ? document.querySelector('#flTime').value : null,
utcNow: new Date().toISOString().slice(0, 19).replace('T', ' '),
localHour: new Date().getHours()})""")
has_extra = [k for k in ("客户名称", "联系人", "电话", "结果标签", "下次", "附件") if k in fw["listHeads"] or True and k in page.inner_text("#subBox .frm")]
rec("D-12", fw["ids"] == ["flContent", "flTime", "flWay"] and fw["listHeads"] == ["时间", "内容", "方式"],
f"写跟进表单仍只有 内容/时间/方式(自由文本) 三项,列表仍缺 跟进人/部门列 ids={fw['ids']} heads={fw['listHeads']}")
utc_match = fw["flTime"] is not None and fw["flTime"][:13] == fw["utcNow"][:13]
rec("D-22", utc_match, f"跟进时间默认值 {fw['flTime']} == 浏览器 UTC now {fw['utcNow']}(非本地 UTC+8,与记档一致)")
page.click("#detailSubtabs button[data-sub='customer']")
page.wait_for_timeout(900)
cust = page.evaluate("""() => {
const text = document.querySelector('#subBox').innerText.replace(/\\n/g, ' | ');
const m = text.match(/[A-Z][A-Z_]{3,}/);
return {raw: m ? m[0] : null, text: text.slice(0, 220)};
}""")
no_fields = all(k not in cust["text"] for k in ("联系人", "联系电话", "客户等级"))
rec("D-13", cust["raw"] is not None and no_fields,
f"客户页签仍缺 联系人/电话/等级,类型仍显示原始枚举 {cust['raw']}(与记档一致): {cust['text'][:160]}")
# --- D-10 看板顶部仅 1 张统计卡;D-11 看板卡字段缺口 ---
page.click("nav.tabs button[data-tab='board']")
page.wait_for_timeout(1200)
stats = page.evaluate("""() => ({
cards: [...document.querySelectorAll('#tab-board .sumcards .card')].map(c => c.innerText.replace(/\\n/g, '')),
sumcardsBlocks: document.querySelectorAll('#tab-board .sumcards').length})""")
rec("D-10", stats["sumcardsBlocks"] == 1 and len(stats["cards"]) == 1 and "冲突待处理" in stats["cards"][0],
f"看板顶部仍仅「冲突待处理」1 张统计卡,无 7 阶段计数卡 {stats['cards']}")
bcard = page.evaluate("""() => {
const c = document.querySelector('#boardBox .bcard');
return c ? c.innerText.replace(/\\n/g, ' | ') : '';
}""")
bmiss = [k for k in ("最近跟进", "中标时间", "超期") if k in bcard]
rec("D-11", not bmiss, f"看板卡仍缺 最近跟进时间/中标时间/超期状态,负责人无部门前缀(与记档一致): {bcard[:120]}")
# --- D-08 列表列差异;D-25 分页无页码/跳至 ---
page.click("nav.tabs button[data-tab='list']")
page.wait_for_timeout(900)
cols = page.evaluate("() => [...document.querySelectorAll('#tab-list thead th')].map(t => t.textContent)")
pager = page.evaluate("""() => ({
btns: [...document.querySelectorAll('#tab-list .pager button')].map(b => b.textContent),
inputs: [...document.querySelectorAll('#tab-list .pager input')].length,
text: document.querySelector('#tab-list .pager').innerText.replace(/\\n/g, ' ')})""")
missing_cols = [c for c in ("NO.", "地区", "销售经理", "客户信息", "来源商机", "品牌", "中标时间", "操作") if c not in cols]
rec("D-08", len(cols) == 9 and missing_cols, f"列表仍为 demo 9 列(缺原型 8 列:{missing_cols})cols={cols}")
rec("D-25", pager["inputs"] == 0 and "跳至" not in pager["text"] and set(pager["btns"]) == {"上一页", "下一页"},
f"分页仍仅 上一页/下一页 + 当前页码,无页码列表/跳至 {pager}")
# --- D-14 / D-19 导航仅三 tab,无分屏、无统计 ---
nav = page.evaluate("() => [...document.querySelectorAll('nav.tabs button')].map(b => b.textContent)")
rec("D-14", nav == ["看板", "列表", "详情"], f"导航仍仅 {nav},无左列右详分屏视图(与记档一致)")
rec("D-19", not any("统计" in t for t in nav), f"无 A5-1-4 统计入口(与记档一致)")
# --- D-17 建档弹窗单步 + 裸 id 手输 ---
page.click("text=+ 新建项目")
page.wait_for_selector("#dlgCreate[open]", timeout=8000)
cr = page.evaluate("""() => ({
steps: document.querySelectorAll('#dlgCreate .step, #dlgCreate [class*=step]').length,
ids: [...document.querySelectorAll('#dlgCreate input, #dlgCreate select')].map(e => e.id),
customerIsInput: document.querySelector('#cCustomer') ? document.querySelector('#cCustomer').tagName : null,
cardIsInput: document.querySelector('#cCard') ? document.querySelector('#cCard').tagName : null,
hasSalesMgr: !!document.querySelector('#cSalesMgr, #cOwner'),
hasProjType: !!document.querySelector('#cProjType, #cType')})""")
rec("D-17", cr["steps"] == 0 and cr["customerIsInput"] == "INPUT" and cr["cardIsInput"] == "INPUT"
and not cr["hasSalesMgr"] and not cr["hasProjType"],
f"仍为单步弹窗、客户/方案卡裸 id INPUT、无销售经理/项目类型字段 {cr}")
page.screenshot(path=str(SHOTS / "r17-d17-create-dialog.png"), full_page=True)
page.click("#dlgCreate button:has-text('取消')")
# --- D-05 / D-21 普通销售可见回退按钮 + 失败后弹窗保持打开 ---
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)
open_proj(projR) # projR 已被推进到 stage3(活跃,他人项目)
acts_s = page.evaluate("() => [...document.querySelectorAll('#detailActions button')].map(b => b.textContent)")
rec("D-05", any("回退" in a for a in acts_s),
f"普通销售打开他人 stage3 项目动作区={acts_s},「回退…」按钮仍可见(与记档一致)")
page.screenshot(path=str(SHOTS / "r17-d05-sales-sees-rollback.png"), full_page=True)
page.click("#detailActions button:has-text('回退…')")
page.wait_for_selector("#dlgRollback[open]", timeout=8000)
page.wait_for_selector("#rbTargets option", state="attached", timeout=8000)
page.select_option("#rbTargets", index=1)
page.click("#dlgRollback .dfoot .ok")
page.wait_for_timeout(1500)
still_open = page.evaluate("() => document.querySelector('#dlgRollback').open")
rec("D-21", still_open is True, f"68005 被拒后回退弹窗仍保持 open={still_open}(与记档一致,需手动取消)")
page.screenshot(path=str(SHOTS / "r17-d21-rollback-stays-open.png"), full_page=True)
browser.close()
# ---------------- D-06 源码层现状(请求竞态守卫) ----------------
src = open(ROOT / "demo" / "index.html", encoding="utf-8").read()
guards = [k for k in ("AbortController", "requestSeq", "seqGuard", "latestReq", "raceGuard") if k in src]
rec("D-06", not guards, f"index.html 仍无请求去重/AbortController/序号守卫(守卫命中={guards},与记档一致)")
kept = sum(1 for r in R if r["kept"])
print(f"\n==== P2/P3 现状抽查:维持记档成立 {kept}/{len(R)} 条,变化 {len(R) - kept} 条 ====")
(AUD / "retest-p23-result.json").write_text(json.dumps(R, ensure_ascii=False, indent=1), encoding="utf-8")
sys.exit(0 if kept == len(R) else 1)