Files
dealerhub/backend/scripts/verify_salesman_h5.py

237 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D2 业务员移动开单 H5 全流程验证(Chrome 移动模拟)。
跑法: python scripts/verify_salesman_h5.py
前置: 后端 dev 在 127.0.0.1:18099, vite(verify配置) 在 127.0.0.1:15173
流程: 登录(alice/default) → 开单tab(客户/仓库/商品/单位/取价/数量/抹零/保存过账)
→ 欠款tab(未结单+余额) → 收款tab(登记收款单) → 断言后端落库
"""
import asyncio
import json
import sys
import urllib.request
BASE = "http://127.0.0.1:18099"
PAGE = "http://127.0.0.1:15173/static/#/salesman"
# D2收尾:残留隔离 —— 每次运行用时间戳唯一收款单号 + 前后计数断言,
# 不再依赖固定 SMH50002(重复跑会撞唯一约束),且不删单(过账有副作用)。
import time as _time
RUN_TAG = _time.strftime("%m%d%H%M%S")
RECEIPT_NO = f"SMH{ RUN_TAG }"
def api(method, path, token=None, tenant="default", body=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Content-Type": "application/json"},
)
if token:
req.add_header("Authorization", f"Bearer {token}")
req.add_header("X-Tenant-Id", tenant)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read() or b"{}")
except Exception:
return e.code, {}
async def main():
from playwright.async_api import async_playwright
errors = []
# --- 后端预检: 登录拿 token ---
import urllib.error # noqa
s, login = api("POST", "/api/v1/auth/token/",
body={"username": "alice", "password": "alice12345"})
assert s == 200, f"login failed: {s} {login}"
token = login["access"]
s, custs = api("GET", "/api/v1/partner/customers/?page_size=200",
token=token)
assert s == 200 and custs["count"] >= 1, f"customers: {s}"
# 前后计数基线:验证前后差值断言(残留隔离,不删单)
s, _b0 = api("GET", "/api/v1/sales/bills/?page_size=1", token=token)
assert s == 200, f"bills baseline: {s}"
bills_before = _b0["count"]
s, _r0 = api("GET", "/api/v1/finance/receipts/?page_size=1", token=token)
assert s == 200, f"receipts baseline: {s}"
receipts_before = _r0["count"]
print(f"[pre] baseline bills={bills_before} receipts={receipts_before} "
f"run_tag={RUN_TAG} receipt_no={RECEIPT_NO}")
s, whs = api("GET", "/api/v1/inventory/warehouses/?page_size=200",
token=token)
assert s == 200 and whs["count"] >= 1, f"warehouses: {s}"
s, prods = api("GET", "/api/v1/catalog/products/?page_size=200",
token=token)
assert s == 200 and prods["count"] >= 1, f"products: {s}"
customer = custs["results"][0]
warehouse = whs["results"][0]
product = prods["results"][0]
print(f"[pre] customer={customer['code']} warehouse={warehouse['code']} "
f"product={product['code']} token=ok")
async with async_playwright() as pw:
browser = await pw.chromium.launch()
# Chrome 移动模拟: Pixel 5 视口 + 触摸 + 移动 UA
ctx = await browser.new_context(
viewport={"width": 393, "height": 851},
has_touch=True,
is_mobile=True,
user_agent=("Mozilla/5.0 (Linux; Android 13; Pixel 5) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0 Mobile Safari/537.36"),
)
page = await ctx.new_page()
page.on("console", lambda m: errors.append(m.text)
if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
await page.goto(PAGE, wait_until="networkidle")
# --- 登录 ---(密码框无 placeholder,用 input 序号定位)
inputs = page.locator(".sm-login input")
await inputs.nth(0).fill("alice")
await inputs.nth(1).fill("alice12345")
await page.get_by_role("button", name="登 录").click()
await page.wait_for_timeout(3000)
body = await page.content()
assert "外勤开单" in body or "开单" in body, "login did not land"
print("[1] login ok (mobile viewport 393x851)")
# --- 开单: 选客户/仓库 ---
# 移动视口下 el-option 常判定不可见,用 JS 直点可见 option
async def pick(select_idx, opt_idx=0):
sels = page.locator(".el-select")
await sels.nth(select_idx).click()
await page.wait_for_timeout(900)
txt = await page.evaluate("""(idx) => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (!vis.length || idx >= vis.length) return 'NONE total='+opts.length;
vis[idx].click();
return 'OK:' + vis[idx].textContent.trim().slice(0, 30);
}""", opt_idx)
await page.wait_for_timeout(1200)
return txt
print("[2] customer:", await pick(0, 0))
print("[2] warehouse:", await pick(1, 0))
# 搜商品并加一行: 取第一行的数量框填 2, 点"加"
await page.wait_for_timeout(2000)
add_btns = page.get_by_role("button", name="加", exact=True)
n_add = await add_btns.count()
assert n_add >= 1, "no product rows rendered"
qty_boxes = page.locator(".sm-item .el-input-number input")
await qty_boxes.first.fill("2")
await add_btns.first.click()
await page.wait_for_timeout(800)
order_title = await page.locator(".sm-order-title").count()
assert order_title == 1, "order section not shown after add"
print("[3] add-to-order ok (qty=2)")
# 抹零选抹角 + 保存并过账
await page.locator(".sm-totals .el-select").click()
await page.wait_for_timeout(700)
print("[4] round_to:",
await page.evaluate("""() => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (vis.length < 3) return 'NONE total='+opts.length;
vis[2].click();
return 'OK:' + vis[2].textContent.trim().slice(0, 20);
}"""))
await page.wait_for_timeout(500)
await page.get_by_role("button", name="保存并过账").click()
await page.wait_for_timeout(4000)
print("[4] submit+confirm clicked")
# --- 欠款 tab ---(tab 项是 div#tab-xxx,直接点)
await page.locator("#tab-debt").click()
await page.wait_for_timeout(2500)
debt_body = await page.locator("#pane-debt").text_content()
print(f"[5] debt tab rendered: {(debt_body or '').strip()[:100]}")
assert "未结" in (debt_body or "") or "暂无欠款" in (debt_body or ""), \
f"debt pane unexpected: {(debt_body or '')[:100]}"
# --- 收款 tab: 填单登记 ---
await page.locator("#tab-receipt").click()
await page.wait_for_timeout(1000)
# 收款面板内第一个 select = 客户
cust_sel = page.locator("#pane-receipt .el-select").first
await cust_sel.scroll_into_view_if_needed()
await cust_sel.click()
await page.wait_for_timeout(900)
print("[6] receipt customer:",
await page.evaluate("""() => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (!vis.length) return 'NONE';
vis[0].click();
return 'OK:' + vis[0].textContent.trim().slice(0, 30);
}"""))
await page.wait_for_timeout(500)
# 关掉可能残留的下拉浮层后再填单号(Esc 关闭 popper)
await page.keyboard.press("Escape")
await page.wait_for_timeout(400)
bill_no = RECEIPT_NO
await page.locator("#pane-receipt input[placeholder]").nth(0).scroll_into_view_if_needed()
await page.locator("#pane-receipt input[placeholder]").nth(0).fill(bill_no)
amt_input = page.locator("#pane-receipt .el-input-number input")
await amt_input.fill("50")
await page.get_by_role("button", name="登记收款").click()
await page.wait_for_timeout(3000)
print("[6] receipt submit clicked")
errs = [e for e in errors if "favicon" not in e.lower()]
print(f"[7] console errors: {len(errs)}")
for e in errs[:10]:
print(" ERR:", e[:200])
await browser.close()
# --- 后端断言落库 ---
s, bills = api("GET", "/api/v1/sales/bills/?page_size=5", token=token)
assert s == 200, f"bills: {s}"
latest = bills["results"][0] if bills["results"] else None
print(f"[8] latest bill: {latest['bill_no'] if latest else None} "
f"state={latest['state'] if latest else None} "
f"total={latest['total_amount'] if latest else None}")
assert latest and latest["state"] == "confirmed", \
f"expected confirmed bill, got {latest}"
s, found = api("GET", f"/api/v1/finance/receipts/?page_size=50&search={RECEIPT_NO}",
token=token)
assert s == 200, f"receipts: {s}"
hit = [r for r in found["results"] if r["bill_no"] == RECEIPT_NO]
assert hit, f"receipt {RECEIPT_NO} not found: {[r['bill_no'] for r in found['results'][:5]]}"
print(f"[9] receipt {RECEIPT_NO} ok amount={hit[0]['amount']}")
# 残留隔离断言:本次恰好新增 1 单 + 1 收款(可重复跑,不删单)
s, _b1 = api("GET", "/api/v1/sales/bills/?page_size=1", token=token)
assert s == 200 and _b1["count"] == bills_before + 1, \
f"bills delta: before={bills_before} after={_b1['count']}"
s, _r1 = api("GET", "/api/v1/finance/receipts/?page_size=1", token=token)
assert s == 200 and _r1["count"] == receipts_before + 1, \
f"receipts delta: before={receipts_before} after={_r1['count']}"
print(f"[9b] delta ok: bills {bills_before}->{_b1['count']}, "
f"receipts {receipts_before}->{_r1['count']}")
s, debts = api("GET", "/api/v1/finance/receivables/?page_size=50",
token=token)
assert s == 200
open_debts = [d for d in debts["results"]
if d["status"] in ("open", "partial")]
assert open_debts, "expected open receivables"
print(f"[10] open receivables: {len(open_debts)}, "
f"first balance={open_debts[0]['balance']}")
real_errors = [e for e in errors if "favicon" not in e.lower()]
assert not real_errors, f"console errors: {real_errors[:5]}"
print("ALL SALESMAN-H5 CHECKS PASSED")
if __name__ == "__main__":
sys.exit(asyncio.run(main()) or 0)