feat(cross_end): add driver-to-settlement E2E test with SQL retry

- Add DriverFreightFlowPage (Android): 28-step flow covering accept/load/unload
- Add FreightAccountingPage (Web): accounting/audit/finance-payment/shipper-payment
- Add db_helper: update_risk_control_status via pymysql
- Fix ensure_logged_in: coordinate tap on 'mine' tab + login check
- Add test_driver_to_settlement.py: full cross-end E2E with SQL retry

Co-authored-by: xst <xst@ntocc.com>
This commit is contained in:
xst
2026-07-22 17:18:52 +08:00
parent 6f5668b7ec
commit f75a6f5ec9
6 changed files with 763 additions and 22 deletions
+70 -22
View File
@@ -54,11 +54,14 @@ def make_ensure_logged_in(
):
"""创建一个 ensure_logged_in 闭包。
策略:坐标点击底部"我的"tab(第4个图标)→ 检查"登录/注册"文本。
无法通过 resourceId 或 text 定位底部导航时用坐标点击兜底。
Args:
section_config: App 端配置
config: 全局配置
account_role: 默认账号角色名(如 "driver""shipper"
home_indicator: 首页元素的 accessibility_id
home_indicator: 未使用,保留兼容
"""
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@@ -68,42 +71,87 @@ def make_ensure_logged_in(
skip_login = section_config.get("skip_login", True)
wait = WebDriverWait(driver, timeout=timeout_s)
try:
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator))
)
return
except Exception:
pass
driver.implicitly_wait(3)
# 坐标点击底部"我的"tab4 tab 均分宽度,第4个中心 x≈0.875*w, y≈h-50
try:
login_input = driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_phone_input")
if login_input.is_displayed():
w = driver.get_window_size()["width"]
h = driver.get_window_size()["height"]
driver.tap([(int(w * 0.875), h - 50)])
driver.implicitly_wait(1)
except Exception as e:
raise RuntimeError(f"无法点击底部导航栏: {e}")
# 判断登录态:检查"登录/注册"文本
login_check = (AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("登录/注册")')
try:
el = driver.find_element(*login_check)
if el.is_displayed():
if skip_login:
raise RuntimeError(
"APP 在登录页,未检测到登录!请在设备上手动完成一次登录(含验证码),"
"APP 登录!请在设备上手动完成一次登录(含验证码),"
"之后重新运行用例即可。\n如需自动化登录,请设置 skip_login: false。"
)
account = config["accounts"].get(account_role, {})
login_input.send_keys(account.get("phone", ""))
driver.find_element(
AppiumBy.ACCESSIBILITY_ID, "login_password_input"
).send_keys(account.get("password", ""))
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_submit_btn").click()
wait.until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, home_indicator))
)
_perform_ui_login(driver, config["accounts"].get(account_role, {}), wait)
return
except RuntimeError:
raise
except Exception:
pass # 没找到"登录/注册" → 已登录
# 回到首页
home_tab = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(0)',
)
try:
wait.until(EC.visibility_of_element_located(home_tab))
driver.find_element(*home_tab).click()
except Exception:
pass
raise RuntimeError("无法确定 APP 的登录状态!请确保 APP 已启动且首页或登录页可见。")
return _ensure_logged_in
def _perform_ui_login(driver, account: dict, wait) -> None:
"""执行 UI 自动登录(skip_login: false 时使用)。"""
from appium.webdriver.common.appiumby import AppiumBy
phone = account.get("phone", "")
password = account.get("password", "")
# 点击登录入口
login_entry = (AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("登录/注册")')
wait.until(lambda d: d.find_element(*login_entry))
driver.find_element(*login_entry).click()
driver.implicitly_wait(1)
# 填写手机号
phone_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/ed_phone")
wait.until(lambda d: d.find_element(*phone_input))
driver.find_element(*phone_input).send_keys(phone)
# 填写密码
password_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/ed_pwd")
driver.find_element(*password_input).send_keys(password)
# 点击登录
login_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_login")
driver.find_element(*login_btn).click()
driver.implicitly_wait(2)
# 回到首页
home_tab = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(0)',
)
try:
wait.until(lambda d: d.find_element(*home_tab))
driver.find_element(*home_tab).click()
except Exception:
pass
def save_failure_screenshot(driver, section_config, platform, request):
"""失败自动截图(各端 conftest teardown 中调用)。"""
if (
@@ -0,0 +1,279 @@
"""管理端 Page Object — 运费核算 + 运单审核 + 垫资打款 完整流程。
基于 resources/selectors/正常流程-web端.md 中的元素定位。
使用直接 hash URL 导航(比菜单点击更可靠),按钮选择器带 fallback 兜底。
URL 路由:
运费核算: #/tms/Carrier/freight
运单审核: #/tms/Carrier/OrderDetailAudit
垫资-财务打款: #/tms/Carrier/dzFinancialapproval
垫资-货主打款: #/tms/Carrier/dzApplicationForPayment
"""
from playwright.sync_api import Page
from tests.web.admin.pages.base_page import BasePage
class FreightAccountingPage(BasePage):
"""管理端 - 运费核算 / 运单审核 / 垫资打款。"""
def __init__(self, page: Page, admin_base_url: str = ""):
super().__init__(page)
self.admin_base_url = admin_base_url
# ════════════════════════════════════════════════════════════
# URL 导航
# ════════════════════════════════════════════════════════════
def _go(self, hash_route: str):
"""直接 hash URL 跳转,比菜单点击稳定。"""
url = f"{self.admin_base_url}/#/{hash_route.lstrip('#').lstrip('/')}"
self.page.goto(url, wait_until="domcontentloaded")
self.page.wait_for_timeout(2000)
# ════════════════════════════════════════════════════════════
# 通用操作
# ════════════════════════════════════════════════════════════
def _fill_order_no(self, order_no: str):
"""搜索栏输入运输单号(多个同名 input 时取第一个)。"""
input_el = self.page.locator('input[placeholder="请输入运输单号"]').first
self.wait_visible(input_el)
input_el.clear()
input_el.fill(order_no)
def _click_search(self):
"""点击查询按钮。"""
btn = self.page.locator("button:has-text('查询')").first
self.wait_visible(btn)
btn.click()
self.page.wait_for_timeout(1000)
def _table_has_data(self) -> bool:
"""检查查询结果是否有数据。"""
try:
self.page.wait_for_selector("table tbody tr", timeout=3_000)
return True
except Exception:
return False
def _click_table_action(self, button_index: int):
"""点击表格固定列操作按钮(纯图标 <i> 按钮,selector 文档路径 + fallback)。
vxe-table 操作列按钮结构: td > div > button:nth-child(N) > span > i
"""
# 精确路径(selector 文档原始定位)
sel = (
".vxe-table--fixed-right-wrapper.scrolling--middle "
".vxe-table--body-wrapper.fixed-right--wrapper "
"table > tbody > tr:nth-child(1) > td > div "
f"> button:nth-child({button_index}) > span > i"
)
# 宽松路径(当 DOM 结构略有变化时兜底)
sel_fallback = (
".vxe-table--fixed-right-wrapper "
"table > tbody > tr:nth-child(1) "
f"> td:last-child > div > button:nth-child({button_index})"
)
for attempt in range(3):
try:
self.page.wait_for_selector("table tbody tr", timeout=5_000)
self.page.wait_for_timeout(300)
btn = self.page.locator(sel).first
if btn.count() == 0:
btn = self.page.locator(sel_fallback).first
self.wait_visible(btn, timeout_ms=5_000)
btn.click()
self.page.wait_for_timeout(800)
return
except Exception:
if attempt == 2:
raise
self.page.wait_for_timeout(1_000)
def _modal(self):
"""返回当前可见弹窗内容容器(.first 避免 strict mode)。"""
return self.page.locator(
".vxe-modal--wrapper.is--visible .vxe-modal--body .vxe-modal--content"
).first
def _scroll_modal(self):
"""滚动弹窗到底部。"""
modal = self._modal()
self.wait_visible(modal)
modal.evaluate("el => el.scrollTop = el.scrollHeight")
self.page.wait_for_timeout(500)
def _retry_click_modal_btn(self, locator, timeout_ms: int = 5_000,
retries: int = 3, wait_after: int = 1500):
"""弹窗内按钮带重试点击(每次重试重新获取元素,防 DOM 置换)。"""
for attempt in range(retries):
el = locator() if callable(locator) else locator
try:
self.wait_visible(el, timeout_ms=timeout_ms)
el.click()
self.page.wait_for_timeout(wait_after)
return
except Exception:
if attempt == retries - 1:
raise
self.page.wait_for_timeout(1_000)
# ════════════════════════════════════════════════════════════
# 运费核算
# ════════════════════════════════════════════════════════════
def do_freight_accounting(self, order_no: str, retry_sql=None):
"""运费核算:导航→搜索→(无数据时 SQL 重试)→编辑→核算。
Args:
order_no: 运输单号
retry_sql: 可调用对象,首次查询无数据时调用
"""
self._go("tms/Carrier/freight")
self._fill_order_no(order_no)
self._click_search()
# 无数据 → 执行 SQL 重试
if not self._table_has_data():
if retry_sql:
retry_sql()
self._fill_order_no(order_no)
self._click_search()
if not self._table_has_data():
raise RuntimeError(f"运费核算查询无数据!单号: {order_no}")
# 点击编辑 (button_index=4, 来自 selector 文档 Step 3)
self._click_table_action(button_index=4)
# 核算按钮 (selector 文档 Step 4)
btn = self._modal().locator(
"form > div:nth-child(4) button:nth-child(4) span"
).first
self._retry_click_modal_btn(btn, wait_after=1500)
self.screenshot("freight_accounting_done")
# ════════════════════════════════════════════════════════════
# 运单审核
# ════════════════════════════════════════════════════════════
def do_waybill_audit(self, order_no: str):
"""运单审核:导航→搜索→审核→滚动弹窗→审核通过。"""
self._go("tms/Carrier/OrderDetailAudit")
self._fill_order_no(order_no)
self._click_search()
# 点击审核 (button_index=2, selector 文档 Step 10)
self._click_table_action(button_index=2)
# 滚动弹窗 (selector 文档 Step 11)
self._scroll_modal()
# 审核通过 (selector 文档 Step 12)
btn = self._modal().locator(
"._btns > button.el-button.el-button--primary.el-button--medium > span"
).first
self._retry_click_modal_btn(btn, wait_after=1500)
self.screenshot("waybill_audit_done")
# ════════════════════════════════════════════════════════════
# 垫资-财务打款
# ════════════════════════════════════════════════════════════
def do_finance_payment(self, order_no: str, verify_code: str = "1234"):
"""垫资-财务打款:导航→搜索→在线支付→滚动→勾选光大/手机→验证码→确认。"""
self._go("tms/Carrier/dzFinancialapproval")
self._fill_order_no(order_no)
self._click_search()
# 在线支付 (button_index=2, selector 文档 Step 18)
self._click_table_action(button_index=2)
self._scroll_modal()
# 勾选光大银行 (selector 文档 Step 20)
try:
radio = self._modal().locator(
"form > div:nth-child(9) > div > div:nth-child(2) "
"> label > span.el-radio__input > span"
).first
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
except Exception:
pass
# 勾选手机号 (selector 文档 Step 21)
try:
radio = self.page.locator(
"[id^='el-collapse-content-'] > div > div:nth-child(3) "
"> label > span.el-radio__input > span"
).first
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
except Exception:
pass
# 获取验证码 (selector 文档 Step 22)
try:
btn = self._modal().locator(
"form > div.el-row > div.el-col.el-col-4 button span"
).first
self._retry_click_modal_btn(btn, timeout_ms=3_000, wait_after=500)
except Exception:
pass
# 输入验证码 (selector 文档 Step 23)
try:
inp = self._modal().locator(
"form > div.el-row > div.el-col.el-col-20 input"
).first
self.wait_visible(inp)
inp.clear()
inp.fill(verify_code)
except Exception:
pass
# 确认打款 (selector 文档 Step 24)
btn = self._modal().locator(
"form > div:nth-child(12) > div > button span"
).first
self._retry_click_modal_btn(btn, wait_after=1500)
self.screenshot("finance_payment_done")
# ════════════════════════════════════════════════════════════
# 垫资-货主打款
# ════════════════════════════════════════════════════════════
def do_shipper_payment(self, order_no: str):
"""垫资-货主打款:导航→搜索→打款→勾选光大→确认打款→确认弹窗。"""
self._go("tms/Carrier/dzApplicationForPayment")
self._fill_order_no(order_no)
self._click_search()
# 打款 (button_index=2, selector 文档 Step 30)
self._click_table_action(button_index=2)
# 勾选光大 (selector 文档 Step 31)
try:
radio = self._modal().locator(
"form > div > div:nth-child(3) > div > div > div:nth-child(3) "
"> label > span.el-radio__label"
).first
self._retry_click_modal_btn(radio, timeout_ms=3_000, wait_after=300)
except Exception:
pass
# 确认打款 (selector 文档 Step 31-2)
btn = self._modal().locator("> div > div > button > span").first
self._retry_click_modal_btn(btn, wait_after=1000)
# message-box 确认 (selector 文档 Step 32)
try:
msg = self.page.locator(
"body > div.el-message-box__wrapper .el-message-box__btns "
"> button.el-button.el-button--default.el-button--small.el-button--primary > span"
)
self._retry_click_modal_btn(msg, timeout_ms=5_000, wait_after=1500)
except Exception:
pass
self.screenshot("shipper_payment_done")
@@ -0,0 +1,212 @@
"""司机 APP 货运流程 Page Object — 货源→接单→装货→卸货→获取单号。
基于 resources/selectors/正常流程-司机.md 中的元素定位。
使用 Appium UiAutomator2 定位策略。
"""
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from tests.app._base_page import AppBasePage
class DriverFreightFlowPage(AppBasePage):
"""司机端 APP - 货运完整流程(货源→接单→装货→卸货→获取单号)。"""
def __init__(self, driver, platform: str = "android"):
super().__init__(driver, platform)
# ── 底部导航 ──
# 0=首页, 1=货源, 2=消息, 3=我的
self.freight_source_tab = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(1)',
)
# ── 货源单列表 ──
self.freight_order_item = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().className("android.widget.RelativeLayout").instance(3)',
)
# ── 数量 ──
self.quantity_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/et_num")
# ── 协议勾选 ──
self.agreement_checkbox = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/check_xieyi")
# ── 接单 ──
self.accept_order_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_bian")
# ── 系统弹窗取消 ──
self.cancel_btn = (AppiumBy.ID, "android:id/button2")
# ── 吨位 ──
self.weight_input = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/et_dun")
# ── 上传图片 ──
self.select_image_btn = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().className("android.widget.ImageView").instance(2)',
)
self.album_btn = (AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("相册")')
self.image_checkbox = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/cb_check")
self.done_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/btn_ok")
# ── 装货/卸货确认 ──
self.load_unload_confirm = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_nick")
# ── 合同 ──
self.contract_webview = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/textHtml")
self.acknowledge_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/tv_affirm")
self.confirm_btn = (AppiumBy.ID, "com.arpa.ynchenggangdriver:id/ok_tv3")
# ════════════════════════════════════════════════════════════
# 基础操作封装
# ════════════════════════════════════════════════════════════
def _wait_and_click(self, locator, timeout_s: float = 10.0):
wait = WebDriverWait(self.driver, timeout=timeout_s)
wait.until(EC.visibility_of_element_located(locator))
self.driver.find_element(*locator).click()
def _wait_and_fill(self, locator, value: str, timeout_s: float = 10.0):
wait = WebDriverWait(self.driver, timeout=timeout_s)
wait.until(EC.visibility_of_element_located(locator))
el = self.driver.find_element(*locator)
el.clear()
el.send_keys(value)
def _tap_by_text(self, text: str, timeout_s: float = 10.0):
locator = (AppiumBy.ANDROID_UIAUTOMATOR, f'new UiSelector().text("{text}")')
self._wait_and_click(locator, timeout_s)
def _safe_tap_by_text(self, text: str, timeout_s: float = 5.0):
"""容错点击:元素不存在时静默跳过。"""
try:
self._tap_by_text(text, timeout_s)
except Exception:
pass
def _swipe_up(self, locator=None, duration_ms: int = 800):
"""向上滑动。指定 locator 时在元素内滑动,否则全屏滑动。"""
if locator:
try:
wait = WebDriverWait(self.driver, timeout=5.0)
el = wait.until(EC.presence_of_element_located(locator))
size = el.size
loc = el.location
start_x = loc["x"] + size["width"] // 2
start_y = loc["y"] + int(size["height"] * 0.8)
end_y = loc["y"] + int(size["height"] * 0.2)
self.driver.swipe(start_x, start_y, start_x, end_y, duration_ms)
return
except Exception:
pass
# 全屏滑动
size = self.driver.get_window_size()
self.driver.swipe(
start_x=size["width"] // 2,
start_y=int(size["height"] * 0.7),
end_x=size["width"] // 2,
end_y=int(size["height"] * 0.3),
duration=duration_ms,
)
# ════════════════════════════════════════════════════════════
# 接单流程 (步骤 1-5)
# ════════════════════════════════════════════════════════════
def accept_order_flow(self, quantity: str = "1"):
"""接单完整流程:货源 tab → 货源单 → 数量 → 协议 → 接单。"""
self._wait_and_click(self.freight_source_tab) # ① 点击货源
self.driver.implicitly_wait(1)
self._wait_and_click(self.freight_order_item) # ② 点击货源单
self.driver.implicitly_wait(2)
self._wait_and_fill(self.quantity_input, quantity) # ③ 填写数量
self._wait_and_click(self.agreement_checkbox) # ④ 勾选协议
self._wait_and_click(self.accept_order_btn) # ⑤ 接单
self.driver.implicitly_wait(2)
# ════════════════════════════════════════════════════════════
# 装货流程 (步骤 6-17)
# ════════════════════════════════════════════════════════════
def load_goods_flow(self, weight: str = "10"):
"""装货完整流程。"""
self._tap_by_text("装货") # ⑥ 点击装货
self.driver.implicitly_wait(1)
self._wait_and_click(self.cancel_btn) # ⑦ 点击取消(系统弹窗)
self.driver.implicitly_wait(1)
self._wait_and_fill(self.weight_input, weight) # ⑧ 输入吨位
self._wait_and_click(self.select_image_btn) # ⑨ 选择图片
self.driver.implicitly_wait(1)
self._wait_and_click(self.album_btn) # ⑩ 相册
self.driver.implicitly_wait(1)
self._wait_and_click(self.image_checkbox) # ⑪ 勾选图片
self._wait_and_click(self.done_btn) # ⑫ 完成
self.driver.implicitly_wait(2)
self._wait_and_click(self.agreement_checkbox) # ⑬ 勾选协议
self._wait_and_click(self.load_unload_confirm) # ⑭ 装货确认
self.driver.implicitly_wait(1)
self._swipe_up(self.contract_webview) # ⑮ 滑动合同
self.driver.implicitly_wait(1)
self._safe_tap_by_text("已知晓", timeout_s=5.0) # ⑯ 已知晓(容错)
self._safe_tap_by_text("确定", timeout_s=5.0) # ⑰ 确定(容错)
self.driver.implicitly_wait(2)
# ════════════════════════════════════════════════════════════
# 卸货流程 (步骤 19-27)
# ════════════════════════════════════════════════════════════
def unload_goods_flow(self, weight: str = "10"):
"""卸货完整流程。"""
self._tap_by_text("卸货") # ⑲ 点击卸货
self.driver.implicitly_wait(1)
self._wait_and_click(self.cancel_btn) # ⑳ 点击取消(系统弹窗)
self.driver.implicitly_wait(1)
self._wait_and_fill(self.weight_input, weight) # ㉑ 输入吨位
self._wait_and_click(self.select_image_btn) # ㉒ 选择图片
self.driver.implicitly_wait(1)
self._wait_and_click(self.album_btn) # ㉓ 相册
self.driver.implicitly_wait(1)
self._wait_and_click(self.image_checkbox) # ㉔ 勾选图片
self._wait_and_click(self.done_btn) # ㉕ 完成
self.driver.implicitly_wait(2)
self._wait_and_click(self.load_unload_confirm) # ㉖ 卸货确认
self.driver.implicitly_wait(1)
self._safe_tap_by_text("确定", timeout_s=5.0) # ㉗ 确定(容错)
self.driver.implicitly_wait(2)
# ════════════════════════════════════════════════════════════
# 获取单号 (步骤 28)
# ════════════════════════════════════════════════════════════
def get_transport_order_no(self, timeout_s: float = 10.0) -> str:
"""获取运输单号。从"运输单号:RHJC..."中正则提取纯单号。"""
import re
locator = (
AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().textContains("运输单号")',
)
wait = WebDriverWait(self.driver, timeout=timeout_s)
el = wait.until(EC.visibility_of_element_located(locator))
full_text = el.text
match = re.search(r"RHJC\S+", full_text)
if match:
return match.group()
return full_text.replace("运输单号:", "").replace("运输单号:", "").strip()
# ════════════════════════════════════════════════════════════
# 全流程
# ════════════════════════════════════════════════════════════
def full_driver_flow(self, quantity: str = "1", load_weight: str = "10",
unload_weight: str = "10") -> str:
"""执行完整司机端流程:接单→装货→卸货→返回运输单号。"""
self.accept_order_flow(quantity)
self.load_goods_flow(load_weight)
self.unload_goods_flow(unload_weight)
return self.get_transport_order_no()
@@ -0,0 +1,129 @@
"""跨端 E2E 测试 — 司机接单装货卸货 + 管理端核算打款完整流程。
业务流程:
司机 APP (Android):
1. 接单: 点击货源 → 选择货源单 → 填写数量 → 勾选协议 → 接单
2. 装货: 装货 → 取消弹窗 → 吨位 → 选图 → 相册 → 勾图 → 完成 → 勾选 → 装货确认 → 合同 → 已知晓 → 确定
3. 卸货: 卸货 → 取消弹窗 → 吨位 → 选图 → 相册 → 勾图 → 完成 → 卸货确认 → 确定
4. 获取运输单号
Web 管理端:
5. 运费核算: URL 跳转 → 输入单号 → 查询 →(无数据时 SQL 重试)→ 编辑 → 核算
6. 运单审核: URL 跳转 → 输入单号 → 查询 → 审核 → 滚动弹窗 → 审核通过
7. 垫资-财务打款: URL 跳转 → 输入单号 → 查询 → 在线支付 → 滚动 →
勾选光大 → 勾选手机号 → 获取验证码 → 输入验证码 → 确认打款
8. 垫资-货主打款: URL 跳转 → 输入单号 → 查询 → 打款 → 勾选光大 → 确认打款 → 确认弹窗
运行:
pytest tests/cross_end/tests/test_driver_to_settlement.py -m cross_end -v -s
前置条件:
- Appium server: http://localhost:4723
- Android 模拟器已启动,司机 APP 已安装并登录过一次 (noReset)
- 管理端 auth 态: python tests/web/admin/save_auth.py --role super_admin
- MySQL 数据库可连接 (pymysql)
"""
import pytest
from tests.cross_end.pages.admin.freight_accounting_page import FreightAccountingPage
from tests.cross_end.pages.app.driver_freight_flow_page import DriverFreightFlowPage
from tests.utils.db_helper import update_risk_control_status
pytestmark = [pytest.mark.cross_end, pytest.mark.p0, pytest.mark.smoke]
class TestDriverToSettlement:
"""完整业务流程: 司机 APP 接单→装货→卸货 → Web 管理端核算→打款。
测试覆盖:
- 司机 APP: 货源浏览、接单、装货(含拍照上传、合同确认)、卸货(含拍照上传)
- Web 管理端: 运费核算、运单审核、垫资-财务打款(含验证码)、垫资-货主打款
- 数据库: SQL 更新风控状态(运费核算查询无数据时自动执行)
"""
def test_driver_to_settlement_full_flow(
self,
admin_page,
admin_config,
app_driver,
ensure_admin_login,
ensure_driver_login,
cross_end_context,
logger,
):
"""验证从司机 APP 接单到管理端核算打款的完整跨端业务流程。"""
logger.info("=" * 60)
logger.info("跨端 E2E: 司机APP → 管理端核算打款 完整流程开始")
logger.info("=" * 60)
# ── 登录确认 ──
ensure_admin_login("super_admin")
ensure_driver_login(app_driver)
# ── 初始化 Page Objects ──
driver_flow = DriverFreightFlowPage(app_driver)
admin_accounting = FreightAccountingPage(admin_page, admin_base_url=admin_config["base_url"])
# ════════════════════════════════════════════════════════════
# Phase 1: 司机 Android APP — 接单 + 装货 + 卸货
# ════════════════════════════════════════════════════════════
logger.info("Phase 1: 司机 APP — 开始接单流程")
driver_flow.accept_order_flow(quantity="1")
driver_flow.screenshot("step_accept_order")
logger.info(" -> 接单流程完成")
logger.info("Phase 1: 司机 APP — 开始装货流程")
driver_flow.load_goods_flow(weight="10")
driver_flow.screenshot("step_load_goods")
logger.info(" -> 装货流程完成")
logger.info("Phase 1: 司机 APP — 开始卸货流程")
driver_flow.unload_goods_flow(weight="10")
driver_flow.screenshot("step_unload_goods")
logger.info(" -> 卸货流程完成")
logger.info("Phase 1: 司机 APP — 获取运输单号")
transport_order_no = driver_flow.get_transport_order_no()
cross_end_context["order_no"] = transport_order_no
logger.info(f" -> 运输单号: {transport_order_no}")
# ════════════════════════════════════════════════════════════
# Phase 2: Web 管理端 — 核算 + 审核 + 打款
# ════════════════════════════════════════════════════════════
logger.info("Phase 2: Web管理端 — 运费核算(首次查询无数据时自动 SQL 重试)")
admin_accounting.do_freight_accounting(
transport_order_no,
retry_sql=lambda: update_risk_control_status(transport_order_no, status=1),
)
cross_end_context["accounting_done"] = True
logger.info(f" -> 运费核算完成: {transport_order_no}")
logger.info("Phase 2: Web管理端 — 运单审核")
admin_accounting.do_waybill_audit(transport_order_no)
cross_end_context["review_done"] = True
logger.info(f" -> 运单审核完成: {transport_order_no}")
logger.info("Phase 2: Web管理端 — 垫资-财务打款")
admin_accounting.do_finance_payment(transport_order_no, verify_code="1234")
logger.info(f" -> 垫资-财务打款完成: {transport_order_no}")
logger.info("Phase 2: Web管理端 — 垫资-货主打款")
admin_accounting.do_shipper_payment(transport_order_no)
logger.info(f" -> 垫资-货主打款完成: {transport_order_no}")
# ════════════════════════════════════════════════════════════
# 最终验证
# ════════════════════════════════════════════════════════════
logger.info("=" * 60)
logger.info("跨端 E2E: 司机APP → 管理端核算打款 完整流程结束!")
logger.info(f" 运输单号: {cross_end_context['order_no']}")
logger.info(f" 核算完成: {cross_end_context['accounting_done']}")
logger.info(f" 审核完成: {cross_end_context['review_done']}")
logger.info("=" * 60)
assert cross_end_context["order_no"], "运输单号不能为空"
assert cross_end_context["accounting_done"], "核算未完成"
assert cross_end_context["review_done"], "审核未完成"
+72
View File
@@ -0,0 +1,72 @@
"""MySQL 数据库操作工具 — 测试中的数据库校验/数据修改。
Usage:
from tests.utils.db_helper import update_risk_control_status
affected = update_risk_control_status("RHJC040026071600021457570057", status=1)
"""
import pymysql
DEFAULT_DB_CONFIG = {
"host": "36.137.133.24",
"port": 3306,
"user": "ntocc",
"password": "Rgxi3!kD9jjXpgzx",
"database": "ntocc",
"charset": "utf8mb4",
}
def get_connection(config: dict | None = None):
"""获取数据库连接。"""
cfg = config or DEFAULT_DB_CONFIG
return pymysql.connect(**cfg)
def execute_sql(sql: str, params: tuple | None = None, config: dict | None = None) -> int:
"""执行 SQLINSERT/UPDATE/DELETE),返回影响行数。"""
conn = get_connection(config)
try:
with conn.cursor() as cursor:
affected = cursor.execute(sql, params)
conn.commit()
return affected
finally:
conn.close()
def query_sql(sql: str, params: tuple | None = None, config: dict | None = None) -> list[dict]:
"""执行查询 SQL,返回结果列表。"""
conn = get_connection(config)
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
cursor.execute(sql, params)
return cursor.fetchall()
finally:
conn.close()
def update_risk_control_status(order_code: str, status: int = 1, config: dict | None = None) -> int:
"""更新运单风控状态。
执行 SQL:
UPDATE tms_order_detail_ext SET risk_control_status = <status>
WHERE order_detail_code IN (
SELECT `code` FROM `tms_order_detail` WHERE order_code = '<order_code>'
)
Args:
order_code: 运输单号(对应 tms_order_detail.order_code
status: 风控状态值,默认 1
config: 数据库连接配置
Returns:
影响行数
"""
sql = (
"UPDATE tms_order_detail_ext SET risk_control_status = %s "
"WHERE order_detail_code IN ("
" SELECT `code` FROM `tms_order_detail` WHERE order_code = %s"
")"
)
return execute_sql(sql, (status, order_code), config)