"""管理端 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): """点击表格固定列操作按钮(纯图标 按钮,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() # 核算按钮 (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 _select_team_leader_payment(self): """在核算编辑弹窗中选择"车队长"+车队长"全额收款"模式。 前置条件:已经在运费核算编辑弹窗中(点击了编辑按钮之后)。 步骤: 1. 点击选择车队长下拉框,选中车队长 2. 点击选择收款模式下拉框,选择车队长全额收款 注意: Element UI 的 el-select-dropdown popper 在 DOM 中可能有多个残留 (旧的 hidden),需要用 nth(-1) 取最晚渲染的那个。 """ # Step 1: 点击选择车队长下拉框 selector_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.page.locator( ".vxe-modal--wrapper.is--visible " "form > div:nth-child(2) > div:nth-child(5) > div > div > div" ).first payment_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() # 审核通过 (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(order_no) # 在线支付 (button_index=2, selector 文档 Step 18) self._click_table_action(button_index=2, order_no=order_no) self._scroll_modal() # 勾选光大银行 (selector 文档 Step 20) def _click_guangda_radio(): return self._modal().locator( "form > div:nth-child(9) > div > div:nth-child(2) " "> label > span.el-radio__input > span" ).first self._try_click("勾选光大银行", _click_guangda_radio) # 勾选手机号 (selector 文档 Step 21) def _click_phone_radio(): return self.page.locator( "[id^='el-collapse-content-'] > div > div:nth-child(3) " "> label > span.el-radio__input > span" ).first self._try_click("勾选手机号", _click_phone_radio) # 获取验证码 (selector 文档 Step 22) def _click_get_code(): return self._modal().locator( "form > div.el-row > div.el-col.el-col-4 button span" ).first self._try_click("获取验证码", _click_get_code, wait_after=500) # 输入验证码 (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 as e: logger.warning(f"可选操作 '输入验证码' 未完成,跳过: {type(e).__name__}") # 确认打款 (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(order_no) # 打款 (button_index=2, selector 文档 Step 30) self._click_table_action(button_index=2, order_no=order_no) # 勾选光大 (selector 文档 Step 31) def _click_guangda_label(): return self._modal().locator( "form > div > div:nth-child(3) > div > div > div:nth-child(3) " "> label > span.el-radio__label" ).first self._try_click("勾选光大", _click_guangda_label) # 确认打款 (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) def _click_msgbox_confirm(): return 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._try_click("message-box 确认", _click_msgbox_confirm, timeout_ms=5_000, wait_after=1500) self.screenshot("shipper_payment_done")