Files
xst dd0877653e refactor(locators): 文本锚点优先的多候选定位 + 失败 DOM 快照
定位健壮性原则: 语义文本 > 组件库稳定 class > 层级结构 > nth-child/绝对路径。

Web (freight_accounting_page):
- 新增 BasePage.first_matching(*候选): 文本锚点优先命中, 旧结构路径兜底
- 13 处定位器改造: 核算/审核通过/确认打款按钮(get_by_role 文本)、
  车队长/收款模式表单(.el-form-item label 锚定)、光大单选(label:has-text)、
  获取验证码/验证码输入(按钮同行锚定)、message-box 确定等
- 手机号单选保留旧结构路径优先(弹窗含多组电话单选, 文本有歧义)
- 已实况验证: 获取验证码/确认打款/光大锚点唯一命中

App (freight_flow_page):
- 货源 tab: text("货源") 文本锚点优先(登录流程持续验证), 位置序号兜底
- 货源单列表项: RecyclerView(lRrcyclerView) 第一个子项, 全局 instance(3) 兜底
- 选图按钮(ImageView instance(2))保留: 需在途订单才能探测, 当前可用

失败诊断:
- Web 失败时 attach 页面 DOM 快照, App 失败时 attach page_source 到 Allure

验收: 实况锚点解析全部唯一命中; 两条 E2E 通过且 DB 终态均为全流程完成
(status=5, 已结算, 双支付完成)。
2026-09-02 14:59:27 +08:00

502 lines
23 KiB
Python

"""管理端 Page Object — 运费核算 + 运单审核 + 垫资打款 完整流程。
基于 resources/selectors/正常流程-web端.md 中的元素定位。
使用直接 hash URL 导航(比菜单点击更可靠),按钮选择器带 fallback 兜底。
等待策略: 全部显式等待(搜索框出现 / 列表数据或空态 / 弹窗出现),
无常驻固定 sleep;无法用条件表达的短暂渲染间隙保留少量 settle 毫秒并注明原因。
URL 路由:
运费核算: #/tms/Carrier/freight
运单审核: #/tms/Carrier/OrderDetailAudit
垫资-财务打款: #/tms/Carrier/dzFinancialapproval
垫资-货主打款: #/tms/Carrier/dzApplicationForPayment
"""
import logging
from playwright.sync_api import Page
from tests.pages.base.web_base_page import BasePage
logger = logging.getLogger(__name__)
class FreightAccountingPage(BasePage):
"""管理端 - 运费核算 / 运单审核 / 垫资打款。"""
# 列表页统一的搜索输入框(4 个路由页面均有)
_ORDER_INPUT = 'input[placeholder="请输入运输单号"]'
# 列表稳定态: 可见数据行 或 vxe-table 空态占位(类名随版本略有差异, 全列出)
_TABLE_READY_SELECTOR = (
"table tbody tr td:visible, "
".vxe-table--empty-block, "
".vxe-table--empty-placeholder-wrapper, "
".vxe-table--empty-placeholder"
)
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 跳转,并等待列表页搜索框出现(显式等待替代固定 sleep)。"""
url = f"{self.admin_base_url}/#/{hash_route.lstrip('#').lstrip('/')}"
self.page.goto(url, wait_until="domcontentloaded")
self.wait_visible(self.page.locator(self._ORDER_INPUT).first)
# ════════════════════════════════════════════════════════════
# 通用操作
# ════════════════════════════════════════════════════════════
def _fill_order_no(self, order_no: str):
"""搜索栏输入运输单号(多个同名 input 时取第一个)。"""
input_el = self.page.locator(self._ORDER_INPUT).first
self.wait_visible(input_el)
input_el.clear()
input_el.fill(order_no)
def _click_search(self, order_no: str | None = None):
"""点击查询按钮,并等待列表刷新为本次查询的结果。
传入 order_no 时等待"第一行包含该单号或出现空态",
避免点到查询响应返回前的旧表格行(未过滤列表的第一行
可能是完全无关的运单)。
"""
btn = self.page.locator("button:has-text('查询')").first
self.wait_visible(btn)
btn.click()
if order_no:
self._wait_search_applied(order_no)
else:
self._wait_table_ready()
def _wait_search_applied(self, order_no: str, timeout_ms: int = 15_000):
"""等待查询结果生效: 第一行文本包含单号, 或出现稳定空态。
查询是异步请求, 响应返回前表格仍显示上一次内容——若不等待,
后续行操作会点在旧列表的行上。
空态判定要求连续两次轮询均为空(规避 vxe-table 加载中的瞬时空块),
且行匹配优先于空态判定。
"""
import time
row1 = self.page.locator(
".vxe-table--main-wrapper table > tbody > tr:nth-child(1)"
)
empty = self.page.locator(
".vxe-table--empty-block, .vxe-table--empty-placeholder-wrapper"
)
deadline = time.monotonic() + timeout_ms / 1000
last_text = ""
empty_streak = 0
while time.monotonic() < deadline:
if row1.count() > 0:
empty_streak = 0
last_text = row1.first.inner_text().replace("\n", " ")
if order_no in last_text:
return
elif empty.count() > 0:
empty_streak += 1
if empty_streak >= 2:
return
else:
empty_streak = 0
self.page.wait_for_timeout(200)
raise RuntimeError(
f"查询结果未包含单号 {order_no}(等待 {timeout_ms}ms)。"
f"当前第一行: {last_text[:120] or '无数据行'}"
)
def _verify_row1_is_order(self, order_no: str):
"""点击行操作前校验: 主表第一行必须包含目标单号(杜绝点错行)。"""
row1 = self.page.locator(
".vxe-table--main-wrapper table > tbody > tr:nth-child(1)"
)
if row1.count() == 0:
raise RuntimeError(f"列表无数据行,无法操作单号 {order_no}")
text = row1.first.inner_text().replace("\n", " ")
if order_no not in text:
raise RuntimeError(
f"列表第一行不是目标运单!期望 {order_no},实际第一行: {text[:120]}"
)
def _wait_table_ready(self, timeout_ms: int = 10_000):
"""等待查询结果渲染稳定: 出现可见数据行或"暂无数据"空态。
空态类名因 vxe-table 版本而异,超时不视为失败——
由调用方通过 _table_has_data 按需判断。
"""
try:
self.page.wait_for_selector(self._TABLE_READY_SELECTOR, timeout=timeout_ms)
except Exception:
logger.warning(f"等待列表稳定超时({timeout_ms}ms),继续按原逻辑执行")
def _table_has_data(self) -> bool:
"""检查查询结果是否有可见数据行(在 _click_search 之后调用)。
用 :visible 避开 vxe-table 固定列渲染的隐藏占位单元格;
数据未就绪时兜底再等 3s(兼容空态类名不匹配的情况)。
"""
if self.page.locator("table tbody tr td:visible").count() > 0:
return True
try:
self.page.wait_for_selector("table tbody tr td:visible", timeout=3_000)
return True
except Exception:
return False
def _click_table_action(self, button_index: int, order_no: str | None = None):
"""点击表格固定列操作按钮(纯图标 <i> 按钮,selector 文档路径 + fallback)。
vxe-table 操作列按钮结构: td > div > button:nth-child(N) > span > i
点击后显式等待操作弹窗出现。
传入 order_no 时先校验第一行就是目标运单,杜绝点错行。
"""
# 精确路径(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 td:visible", timeout=8_000)
self.page.wait_for_timeout(300) # 行渲染后按钮绑定有小延迟, 短暂 settle
if order_no:
self._verify_row1_is_order(order_no)
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.wait_visible(self._modal(), timeout_ms=10_000)
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(300) # 滚动触发底部按钮渲染的 settle
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 _try_click(self, desc: str, click_fn, timeout_ms: int = 3_000,
wait_after: int = 300):
"""可选弹窗内操作:失败时记录日志并跳过(不静默吞掉)。
页面部分控件(银行单选/手机号单选/验证码)仅在特定单据状态下展示,
缺失属正常分支,但跳过必须留痕。
"""
try:
self._retry_click_modal_btn(click_fn(), timeout_ms=timeout_ms, wait_after=wait_after)
except Exception as e:
logger.warning(f"可选操作 '{desc}' 未完成,跳过: {type(e).__name__}")
# ════════════════════════════════════════════════════════════
# 运费核算
# ════════════════════════════════════════════════════════════
def do_freight_accounting(self, order_no: str, retry_sql=None, team_leader_payment: bool = False):
"""运费核算:导航→搜索→(无数据时 SQL 重试)→编辑→[可选:选择车队长+收款模式]→核算。
Args:
order_no: 运输单号
retry_sql: 可调用对象,首次查询无数据时调用
team_leader_payment: True=车队长全额收款模式(核算前额外选择车队长和收款模式)
"""
self._go("tms/Carrier/freight")
self._fill_order_no(order_no)
self._click_search(order_no)
# 无数据 → 执行 SQL 重试
if not self._table_has_data():
if retry_sql:
try:
retry_sql()
except Exception as e:
raise RuntimeError(f"运费核算 SQL 重试失败!单号: {order_no}, 错误: {e}")
self._fill_order_no(order_no)
self._click_search(order_no)
if not self._table_has_data():
raise RuntimeError(f"运费核算查询无数据!单号: {order_no}")
# 点击编辑 (button_index=4, 来自 selector 文档 Step 3)
self._click_table_action(button_index=4, order_no=order_no)
# ── 车队长全额收款模式:核算前选择车队长 + 收款模式 ──
if team_leader_payment:
self._select_team_leader_payment()
# 核算按钮 (文本锚点优先: 按钮名"核算"; 结构路径兜底)
modal = self._modal()
btn = self.first_matching(
modal.get_by_role("button", name="核算"),
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 _select_team_leader_payment(self):
"""在核算编辑弹窗中选择"车队长"+车队长"全额收款"模式。
前置条件:已经在运费核算编辑弹窗中(点击了编辑按钮之后)。
步骤:
1. 点击选择车队长下拉框,选中车队长
2. 点击选择收款模式下拉框,选择车队长全额收款
注意: Element UI 的 el-select-dropdown popper 在 DOM 中可能有多个残留
(旧的 hidden),需要用 nth(-1) 取最晚渲染的那个。
"""
# Step 1: 点击选择车队长下拉框(表单 label 文本锚点优先,结构路径兜底)
modal = self._modal()
selector_input = self.first_matching(
modal.locator(".el-form-item:has-text('车队长') input"),
self.page.locator(
"#app > div > div.main-container.hasTagsView > section > div > div > div "
"> div.vxe-modal--wrapper.type--modal.is--animat.lock--view.is--mask.is--visible.is--active "
"> div > div.vxe-modal--body > div.vxe-modal--content > div > form "
"> div:nth-child(2) > div:nth-child(4) > div > div > div > div > input"
),
).first
self.wait_visible(selector_input, timeout_ms=5_000)
selector_input.click()
# Step 2: 等待下拉列表渲染后点击车队长选项(取最后一个下拉弹窗,它是最新渲染的)
team_leader_option = self.page.locator(
"body > div.el-select-dropdown.el-popper "
"> div.el-scrollbar > div.el-select-dropdown__wrap.el-scrollbar__wrap "
"> ul > li"
).nth(-1)
self.wait_visible(team_leader_option, timeout_ms=5_000)
team_leader_option.click()
# Step 3: 等待收款模式 el-select 解除 disabled 后再点击
# Element UI 的 disabled 加在 el-select 外层的 div 上,不是 input 本身
# 所以需要等包含 is-disabled class 的上层 div 消失
payment_select_wrapper = self.first_matching(
modal.locator(".el-form-item:has-text('收款模式') .el-select"),
self.page.locator(
".vxe-modal--wrapper.is--visible "
"form > div:nth-child(2) > div:nth-child(5) > div > div > div"
),
).first
payment_input = self.first_matching(
modal.locator(".el-form-item:has-text('收款模式') input"),
self.page.locator(
".vxe-modal--wrapper.is--visible "
"form > div:nth-child(2) > div:nth-child(5) input"
),
).first
self.wait_visible(payment_input, timeout_ms=5_000)
for _ in range(30):
cls = payment_select_wrapper.get_attribute("class") or ""
if "is-disabled" not in cls:
break
self.page.wait_for_timeout(200)
else:
raise RuntimeError("收款模式下拉框始终未解除 disabled,可能车队长选择未生效")
payment_input.click()
# Step 4: 等待下拉项渲染后点击"车队长全额收款"
full_payment_option = self.page.locator(
"body > div.el-select-dropdown.el-popper "
"> div.el-scrollbar > div.el-select-dropdown__wrap.el-scrollbar__wrap "
"> ul > li.el-select-dropdown__item.selected"
).nth(-1)
self.wait_visible(full_payment_option, timeout_ms=5_000)
full_payment_option.click()
self.page.wait_for_timeout(500) # 下拉选中后表单联动的 settle
# ════════════════════════════════════════════════════════════
# 运单审核
# ════════════════════════════════════════════════════════════
def do_waybill_audit(self, order_no: str):
"""运单审核:导航→搜索→审核→滚动弹窗→审核通过。"""
self._go("tms/Carrier/OrderDetailAudit")
self._fill_order_no(order_no)
self._click_search(order_no)
# 点击审核 (button_index=2, selector 文档 Step 10)
self._click_table_action(button_index=2, order_no=order_no)
# 滚动弹窗 (selector 文档 Step 11)
self._scroll_modal()
# 审核通过 (文本锚点优先: 按钮区"通过"; 结构路径兜底)
modal = self._modal()
btn = self.first_matching(
modal.locator("._btns button:has-text('通过')"),
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(order_no)
# 在线支付 (button_index=2, selector 文档 Step 18)
self._click_table_action(button_index=2, order_no=order_no)
self._scroll_modal()
# 勾选光大银行 (label 文本锚点优先; 结构路径兜底)
def _click_guangda_radio():
return self.first_matching(
self._modal().locator("label:has-text('光大')"),
self._modal().locator(
"form > div:nth-child(9) > div > div:nth-child(2) "
"> label > span.el-radio__input > span"
),
timeout_ms=3_000,
).first
self._try_click("勾选光大银行", _click_guangda_radio)
# 勾选手机号 — 弹窗内有多组电话单选, 旧结构路径选中的才是业务正确项(优先保留);
# 手机号格式正则仅作兜底
def _click_phone_radio():
import re as _re
return self.first_matching(
self.page.locator(
"[id^='el-collapse-content-'] > div > div:nth-child(3) "
"> label > span.el-radio__input > span"
),
self.page.locator(
".el-radio__label", has_text=_re.compile(r"^1[3-9]\d{9}$")
),
timeout_ms=3_000,
).first
self._try_click("勾选手机号", _click_phone_radio)
# 获取验证码 (按钮文本已实锤探测)
def _click_get_code():
return self.first_matching(
self._modal().get_by_role("button", name="获取验证码"),
self._modal().locator(
"form > div.el-row > div.el-col.el-col-4 button span"
),
timeout_ms=3_000,
).first
self._try_click("获取验证码", _click_get_code, wait_after=500)
# 输入验证码 — form 直接子行中含"获取验证码"按钮的那个行里的输入框 (替代列结构路径)
try:
inp = self.first_matching(
self._modal().locator(
"form > div.el-row:has(button:has-text('获取验证码')) input"
),
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 as e:
logger.warning(f"可选操作 '输入验证码' 未完成,跳过: {type(e).__name__}")
# 确认打款 (按钮文本已实锤探测) — 关键步骤, 失败必须抛出
btn = self.first_matching(
self._modal().get_by_role("button", name="确认打款"),
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(order_no)
# 打款 (button_index=2, selector 文档 Step 30)
self._click_table_action(button_index=2, order_no=order_no)
# 勾选光大 (label 文本锚点优先; 结构路径兜底)
def _click_guangda_label():
return self.first_matching(
self._modal().locator("label:has-text('光大')"),
self._modal().locator(
"form > div > div:nth-child(3) > div > div > div:nth-child(3) "
"> label > span.el-radio__label"
),
timeout_ms=3_000,
).first
self._try_click("勾选光大", _click_guangda_label)
# 确认打款 (按钮文本已实锤探测) — 关键步骤, 失败必须抛出
btn = self.first_matching(
self._modal().get_by_role("button", name="确认打款"),
self._modal().locator("> div > div > button > span"),
).first
self._retry_click_modal_btn(btn, wait_after=1000)
# message-box 确认 (文本锚点优先; 结构路径兜底)
def _click_msgbox_confirm():
return self.first_matching(
self.page.locator(".el-message-box__btns button:has-text('确定')"),
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"
),
timeout_ms=3_000,
)
self._try_click("message-box 确认", _click_msgbox_confirm, timeout_ms=5_000, wait_after=1500)
self.screenshot("shipper_payment_done")