from __future__ import annotations

import json
import io
import os
import re
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path

from playwright.sync_api import Frame, Page, expect

from e2e.pages.tenant_auth_page import TenantAuthPage
from e2e.pages.wanzhanggui_push_page import WANZHANGGUI_CONSOLE_URL
from e2e.utils.bug_reporter import BugReporter
from e2e.utils.data_factory import (
    DISPATCH_PRICE_PERCENT_MAX,
    DISPATCH_PRICE_PERCENT_MIN,
    default_dispatch_price_percent,
)
from e2e.utils.image_captcha import ImageCaptchaSolver, decode_data_image
from e2e.utils.env_config import assert_url_allowed_for_env


WANYOUJI_CONSOLE_URL = "https://test-merchant-lianlianyun.lianlianwz.com/productConsole?productCode=P20250814095939369041"
WANYOUJI_WORKSHOP_ORDER_LIST_URL = "https://test-orbit.lianlianwz.com/order"
WANYOUJI_ORDER_HALL_URL = "https://test-orbit.lianlianwz.com/orderHall"
WANZHANGGUI_ORDER_LIST_URL = "https://test-order-pc.lianlianwz.com/orderManage/orderList"
PROD_WANZHANGGUI_CONSOLE_URL = "https://merchant-lianlianyun.shunfju.com/productConsole?productCode=P20251222184412861622"
PROD_WANZHANGGUI_ORDER_LIST_URL = "https://order-pc.shunfju.com/orderManage/orderList"
WANZHANGGUI_MAIN_APPEAL_LIST_URL = "https://test-order-pc.lianlianwz.com/orderManage/orderAppeal"
WANZHANGGUI_SUB_ORDER_PENALTY_LIST_URL = "https://test-order-pc.lianlianwz.com/orderManage/subOrderPunish"
WANZHANGGUI_SUPPLY_AUTH_URL = "https://test-futong-pc.lianlianwz.com/merchantSupplyAuth"
GUARANTEE_PLATFORM_FUND_URL = "https://test-merchant-lianlianyun.lianlianwz.com/staticMerchant?menu=fund"
ORDER_SOURCE_SNAPSHOT_PATH = Path("runtime/order_source_options.json")
ORDER_SOURCE_HISTORICAL_EVIDENCE = [
    "runtime/order_probe/05_点击_手工录单_iframe.txt",
    "runtime/order_probe/service_project/modal_text.txt",
    "runtime/probes/wanzhanggui_revoke_order",
]


@dataclass(frozen=True)
class ManualOrderData:
    source_order_no: str
    order_title: str
    strategy_name: str
    strategy_type: str
    main_order_no: str = ""
    receive_price: str = "80"
    dispatch_price: str = "70"
    dispatch_price_mode: str = ""
    dispatch_price_percent: str = ""
    sub_order_title: str = ""
    security_deposit: str = "0"
    efficiency_deposit: str = "0"
    service_hours: str = "14"
    boss_phone: str = "13800138000"
    contact_phone: str = "13800138000"
    game_name: str = "王者"
    game_region: str = "WeGame"
    game_server: str = "微信2区-国士无双"
    service_project: str = "排位"
    game_account: str = "41275199"
    game_password: str = "test123456"
    role_name: str = "E2E测试角色"
    customer_qq: str = "41275199"
    customer_wechat: str = "17740687725"

    @property
    def ai_text(self) -> str:
        return "\n".join(
            [
                "订单来源：拼多多",
                f"游戏：{self.game_name}",
                f"区服：{self.game_region} {self.game_server}",
                f"服务项目：{self.service_project}",
                f"来源单号：{self.source_order_no}",
                f"订单标题：{self.order_title}",
                f"角色名：{self.role_name}",
                f"游戏账号：{self.game_account}",
                f"游戏密码：{self.game_password}",
                "当前段位：青铜I",
                "目标段位：钻石I",
                f"代练时限：{self.service_hours}小时",
                f"收单价：{self.receive_price}元",
                f"联系电话：{self.contact_phone}",
                f"客户QQ：{self.customer_qq}",
                f"客户微信：{self.customer_wechat}",
            ]
        )


class WanzhangguiOrderPage(TenantAuthPage):
    def __init__(self, page: Page, tenant_home_url: str, bug_reporter: BugReporter | None = None):
        super().__init__(page, tenant_home_url, bug_reporter)

    def open_order_management(self) -> Frame:
        self.goto_wanzhanggui_console()
        self.wait_for_enterprise_console_shell()
        self.page.wait_for_timeout(1200)
        self.dismiss_transient_order_error()
        try:
            if self.is_production():
                self.click_sidebar_text("商家订单管理列表", right_edge=True)
                self.page.wait_for_timeout(800)
                self.click_sidebar_text("订单管理列表")
            else:
                self.click_sidebar_text("商家管理", right_edge=True)
                self.page.wait_for_timeout(800)
                self.click_sidebar_path(
                    "/orderManage/orderList",
                    app_hint="Saas-福单",
                    fallback_texts=["订单管理"],
                )
        except AssertionError:
            self.force_wanzhanggui_order_list_url()
        self.page.wait_for_timeout(3500)

        for attempt in range(3):
            self.dismiss_transient_order_error()
            frame = self.order_frame()
            if "/orderManage/orderList" not in frame.url:
                frame.evaluate("url => { window.location.href = url; }", self.order_list_url())
                self.page.wait_for_timeout(3000)
            try:
                return self.wait_for_order_list_ready()
            except AssertionError:
                if attempt == 2:
                    raise
                self.dismiss_transient_order_error()
                self.click_sidebar_text("订单管理列表" if self.is_production() else "订单管理")
                self.page.wait_for_timeout(3500)
        return self.wait_for_order_list_ready()

    def force_wanzhanggui_order_list_url(self) -> None:
        target_url = self.order_list_url()
        assert_url_allowed_for_env(target_url)
        for frame in self.page.frames:
            if "test-order-pc" in frame.url or "order-pc.shunfju.com" in frame.url:
                try:
                    frame.evaluate("url => { window.location.href = url; }", target_url)
                    return
                except Exception:
                    continue
        self.goto(target_url)

    def is_production(self) -> bool:
        return "shunfju.com" in self.tenant_home_url

    def goto_wanzhanggui_console(self) -> None:
        self.goto(PROD_WANZHANGGUI_CONSOLE_URL if self.is_production() else WANZHANGGUI_CONSOLE_URL)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(3000)

    def order_list_url(self) -> str:
        return PROD_WANZHANGGUI_ORDER_LIST_URL if self.is_production() else WANZHANGGUI_ORDER_LIST_URL

    def wait_for_order_list_ready(self) -> Frame:
        last_text = ""
        for attempt in range(8):
            frame = self.order_frame()
            try:
                last_text = frame.locator("body").inner_text(timeout=3000)
                if self.has_transient_order_error(last_text):
                    self.dismiss_transient_order_error(frame)
                    self.page.wait_for_timeout(1200)
                    continue
                if ("手工录单" in last_text or "抖音录单" in last_text) and "订单编号" in last_text:
                    return frame
            except Exception:
                pass
            if attempt in {2, 5}:
                try:
                    frame.evaluate("url => { window.location.href = url; }", self.order_list_url())
                except Exception:
                    try:
                        self.page.reload(wait_until="domcontentloaded")
                    except Exception:
                        pass
                self.page.wait_for_timeout(2500)
                continue
            if attempt == 4:
                try:
                    self.click_sidebar_path(
                        "/orderManage/orderList",
                        app_hint="Saas-福单",
                        fallback_texts=["订单管理列表", "商家订单管理列表"] if self.is_production() else ["订单管理"],
                    )
                except Exception:
                    pass
            self.page.wait_for_timeout(1000)
        raise AssertionError(f"Wanzhanggui order list did not become ready; text={last_text[:1000]}")

    def open_main_order_appeal_list(self) -> Frame:
        self.goto(WANZHANGGUI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        self.page.wait_for_timeout(1200)
        self.dismiss_transient_order_error()
        self.click_sidebar_text("商家管理", right_edge=True)
        self.page.wait_for_timeout(800)
        try:
            self.click_sidebar_path(
                "/orderManage/orderAppeal",
                app_hint="Saas-福单",
                fallback_texts=["主单申诉单"],
            )
            self.page.wait_for_timeout(2500)
        except Exception:
            pass

        frame = self.order_frame()
        if "orderAppeal" not in frame.url:
            frame.evaluate("url => { window.location.href = url; }", WANZHANGGUI_MAIN_APPEAL_LIST_URL)
            self.page.wait_for_timeout(3000)
        return self.wait_for_main_order_appeal_list_ready()

    def wait_for_main_order_appeal_list_ready(self) -> Frame:
        last_text = ""
        for attempt in range(8):
            frame = self.order_frame()
            try:
                last_text = frame.locator("body").inner_text(timeout=3000)
                if self.has_transient_order_error(last_text):
                    self.dismiss_transient_order_error(frame)
                    self.page.wait_for_timeout(1200)
                    continue
                if "申诉" in last_text and ("主单" in last_text or "订单" in last_text):
                    return frame
            except Exception:
                pass
            if attempt == 4:
                try:
                    frame.evaluate("url => { window.location.href = url; }", WANZHANGGUI_MAIN_APPEAL_LIST_URL)
                except Exception:
                    pass
            self.page.wait_for_timeout(1000)
        raise AssertionError(f"Wanzhanggui main order appeal list did not become ready; text={last_text[:1000]}")

    def open_sub_order_penalty_list(self) -> Frame:
        self.goto(WANZHANGGUI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        self.page.wait_for_timeout(1200)
        self.dismiss_transient_order_error()
        self.click_sidebar_text("商家管理", right_edge=True)
        self.page.wait_for_timeout(800)
        self.click_wanzhanggui_sidebar_leaf_exact("子单处罚单")
        self.page.wait_for_timeout(3000)
        return self.wait_for_sub_order_penalty_list_ready()

    def wait_for_sub_order_penalty_list_ready(self) -> Frame:
        last_text = ""
        for attempt in range(8):
            frame = self.order_frame()
            try:
                last_text = frame.locator("body").inner_text(timeout=3000)
                if self.has_transient_order_error(last_text):
                    self.dismiss_transient_order_error(frame)
                    self.page.wait_for_timeout(1200)
                    continue
                if "处罚单号" in last_text and "上游单号" in last_text:
                    return frame
                if "处罚" in last_text and ("子单" in last_text or "订单" in last_text) and "查 询" in last_text:
                    return frame
            except Exception:
                pass
            if attempt == 4:
                try:
                    self.click_wanzhanggui_sidebar_leaf_exact("子单处罚单")
                except Exception:
                    pass
            self.page.wait_for_timeout(1000)
        raise AssertionError(f"Wanzhanggui sub order penalty list did not become ready; text={last_text[:1000]}")

    def click_wanzhanggui_sidebar_leaf_exact(self, text: str) -> None:
        clicked = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 220 && item.r.y > 40)
                  .filter(item => item.r.width > 20 && item.r.width < 220 && item.r.height > 10 && item.r.height < 60)
                  .filter(item => item.value === text)
                  .sort((a, b) => {
                    const aRow = a.r.height >= 30 ? 0 : 1;
                    const bRow = b.r.height >= 30 ? 0 : 1;
                    return aRow - bRow || a.r.y - b.r.y || (b.r.width * b.r.height) - (a.r.width * a.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                let clickable = target.el;
                for (let node = target.el; node && node !== document.body; node = node.parentElement) {
                  const r = node.getBoundingClientRect();
                  if (r.x >= 0 && r.x < 220 && r.width > 60 && r.width < 220 && r.height >= 24 && r.height <= 60) {
                    clickable = node;
                    break;
                  }
                }
                const r = clickable.getBoundingClientRect();
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }""",
            text,
        )
        if not clicked:
            body_text = self.page.locator("body").inner_text(timeout=5000)
            raise AssertionError(f"未找到丸掌柜左侧菜单：{text}；text={body_text[:1200]}")
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)

    def order_frame(self) -> Frame:
        main_frame = self.page.main_frame
        if "test-order-pc" in main_frame.url or "order-pc.shunfju.com" in main_frame.url:
            return main_frame
        for frame in self.page.frames:
            if "test-order-pc" in frame.url or "order-pc.shunfju.com" in frame.url:
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"Wanzhanggui order iframe not found; frames={urls}")

    def has_transient_order_error(self, text: str) -> bool:
        compact = re.sub(r"\s+", "", text or "")
        return any(
            keyword in compact
            for keyword in [
                "订单异常",
                "订单服务异常",
                "订单加载异常",
                "系统异常",
                "服务异常",
                "网络异常",
                "接口异常",
            ]
        )

    def dismiss_transient_order_error(self, frame: Frame | None = None) -> None:
        script = """() => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-message, .ant-notification, .ant-alert, .ant-popover, .ant-popconfirm'))
              .filter(visible)
              .filter(el => /订单异常|订单服务异常|订单加载异常|系统异常|服务异常|网络异常|接口异常/.test(el.innerText || el.textContent || ''));
            const root = roots.pop();
            if (!root) return false;
            const buttons = Array.from(root.querySelectorAll('button, .ant-modal-close, [aria-label="Close"], [class*="close"]'))
              .filter(visible);
            const confirm = buttons.find(el => /知道了|确定|确认|关闭|取 消|取消/.test(el.innerText || el.textContent || '')) || buttons[0];
            if (confirm) {
              confirm.click();
              return true;
            }
            root.remove();
            return true;
        }"""
        closed = False
        try:
            closed = bool((frame or self.page).evaluate(script))
        except Exception:
            closed = False
        if not closed and frame is not None:
            try:
                closed = bool(self.page.evaluate(script))
            except Exception:
                closed = False
        if closed:
            self.page.wait_for_timeout(800)

    def open_workshop_authorization(self) -> Frame:
        self.goto(WANZHANGGUI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        self.ensure_sidebar_child_visible("履约工作室/平台", "工作室管理")
        self.ensure_sidebar_child_visible("工作室管理", "工作室授权")
        self.click_sidebar_text("工作室授权")
        self.page.wait_for_timeout(2500)
        return self.futong_frame_by_path("/merchantSupplyAuth", target_url=WANZHANGGUI_SUPPLY_AUTH_URL)

    def text_has_expired_login(self, text: str) -> bool:
        return any(
            keyword in text
            for keyword in [
                "登录已过期",
                "认证失败",
                "重新登录请求失败",
                "请重新登录",
                "登录失效",
            ]
        )

    def relogin_current_main_account_if_possible(self) -> bool:
        phone = getattr(self.page, "_e2e_current_main_account", "")
        if not phone:
            return False
        try:
            from e2e.utils.account_store import account_store

            account = account_store().find(phone=phone)
        except Exception:
            account = None
        if not account or not account.password:
            return False
        self.clear_login_state()
        self.login_main_account(phone, account.password)
        return True

    def ensure_sidebar_child_visible(self, parent_text: str, child_text: str) -> None:
        last_text = ""
        for _ in range(4):
            if self.sidebar_text_visible(child_text):
                return
            self.click_sidebar_text(parent_text, right_edge=True)
            self.page.wait_for_timeout(1000)
            last_text = self.page.locator("body").inner_text(timeout=5000)
            if child_text in last_text:
                return
        raise AssertionError(
            f"左侧菜单 {parent_text} 未成功展开，未找到子菜单 {child_text}；"
            f"当前页面文本：{last_text[:1000]}"
        )

    def sidebar_text_visible(self, text: str) -> bool:
        try:
            self.sidebar_box(text, parent=False)
            return True
        except AssertionError:
            return False

    def futong_frame_by_path(self, path: str, target_url: str | None = None) -> Frame:
        if target_url:
            for frame in self.page.frames:
                if "test-futong-pc" in frame.url:
                    if path not in frame.url:
                        assert_url_allowed_for_env(target_url)
                        frame.goto(target_url, wait_until="domcontentloaded")
                        self.page.wait_for_timeout(1500)
                    return frame
        for frame in self.page.frames:
            if "test-futong-pc" in frame.url and path in frame.url:
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到丸掌柜业务 iframe {path}，当前 frames：{urls}")

    def ensure_workshop_guarantee(self, workshop_name: str, enabled: bool) -> str:
        last_error: Exception | None = None
        for attempt in range(2):
            frame = self.open_workshop_authorization()
            try:
                row_text = self.workshop_authorization_row_text(frame, workshop_name)
                break
            except AssertionError as exc:
                last_error = exc
                try:
                    text = frame.locator("body").inner_text(timeout=5000)
                except Exception:
                    text = str(exc)
                if attempt == 0 and self.text_has_expired_login(text) and self.relogin_current_main_account_if_possible():
                    continue
                raise
        else:
            raise AssertionError(f"工作室授权列表未找到工作室：{workshop_name}") from last_error
        if self.guarantee_enabled_from_row(row_text) == enabled:
            return row_text

        action_texts = ["开启担保", "启用担保"] if enabled else ["关闭担保", "停用担保"]
        clicked = self.click_workshop_authorization_action(frame, workshop_name, action_texts)
        assert clicked, (
            f"工作室授权列表未找到{action_texts[0]}操作；workshop={workshop_name}; row_text={row_text}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(2500)
        row_text = self.workshop_authorization_row_text(frame, workshop_name)
        assert self.guarantee_enabled_from_row(row_text) == enabled, (
            f"工作室担保状态切换后不符合预期；workshop={workshop_name}; expected={enabled}; row_text={row_text}"
        )
        return row_text

    def search_workshop_authorization(self, frame: Frame, workshop_name: str) -> None:
        last_text = ""
        for attempt in range(4):
            if attempt:
                self.click_optional_frame_text(frame, "重 置") or self.click_optional_frame_text(frame, "重置")
                self.page.wait_for_timeout(800)
            filled = self.fill_input_near_label(frame, "供应链名称", workshop_name) or self.fill_input_near_label(
                frame,
                "工作室名称",
                workshop_name,
            )
            if filled and not (self.click_optional_frame_text(frame, "查 询") or self.click_optional_frame_text(frame, "查询")):
                inputs = frame.locator("input:visible")
                if inputs.count():
                    inputs.first.press("Enter")
            self.page.wait_for_timeout(2000 + attempt * 700)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if workshop_name in last_text:
                return
        raise AssertionError(f"工作室授权列表搜索后未找到工作室：{workshop_name}；text={last_text[:1500]}")

    def workshop_authorization_row_text(self, frame: Frame, workshop_name: str) -> str:
        for _ in range(4):
            row_text = str(
                frame.evaluate(
                    """workshopName => {
                        const norm = text => String(text || '').replace(/\\s+/g, '').trim();
                        const visible = el => {
                          const r = el.getBoundingClientRect();
                          return r.width > 0 && r.height > 0;
                        };
                        const rowNameCell = row => {
                          const cells = Array.from(row.querySelectorAll('td, [role="cell"]'));
                          const exactCell = cells.find(cell => norm(cell.innerText || cell.textContent) === norm(workshopName));
                          if (exactCell) return exactCell;
                          const table = row.closest('table');
                          if (!table) return null;
                          const headers = Array.from(table.querySelectorAll('thead th, [role="columnheader"]'))
                            .map((th, index) => ({ index, text: norm(th.innerText || th.textContent) }));
                          const header = headers.find(item => item.text === '工作室名称') ||
                            headers.find(item => item.text.includes('工作室名称'));
                          if (!header) return null;
                          return cells[header.index] || null;
                        };
                        const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                          .map(row => ({ row, text: (row.innerText || row.textContent || '').trim() }))
                          .filter(item => visible(item.row));
                        const exactRows = rows.filter(item => {
                          const cell = rowNameCell(item.row);
                          return cell && norm(cell.innerText || cell.textContent) === norm(workshopName);
                        });
                        return exactRows[0]?.text || '';
                    }""",
                    workshop_name,
                )
            )
            if row_text:
                return row_text
            self.page.wait_for_timeout(1000)
        raise AssertionError(
            f"工作室授权列表未找到工作室：{workshop_name}；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )

    @staticmethod
    def guarantee_enabled_from_row(row_text: str) -> bool:
        if "关闭担保" in row_text:
            return True
        if "开启担保" in row_text:
            return False
        lines = [line.strip() for line in row_text.splitlines() if line.strip()]
        for index, line in enumerate(lines):
            if "是否开启担保" in line and index + 1 < len(lines):
                return lines[index + 1] == "是"
        return "是" in lines and "否" not in lines

    def click_workshop_authorization_action(self, frame: Frame, workshop_name: str, action_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """({ workshopName, actionTexts }) => {
                const norm = text => String(text || '').replace(/\\s+/g, '').trim();
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rowNameCell = row => {
                  const cells = Array.from(row.querySelectorAll('td, [role="cell"]'));
                  const exactCell = cells.find(cell => norm(cell.innerText || cell.textContent) === norm(workshopName));
                  if (exactCell) return exactCell;
                  const table = row.closest('table');
                  if (!table) return null;
                  const headers = Array.from(table.querySelectorAll('thead th, [role="columnheader"]'))
                    .map((th, index) => ({ index, text: norm(th.innerText || th.textContent) }));
                  const header = headers.find(item => item.text === '工作室名称') ||
                    headers.find(item => item.text.includes('工作室名称'));
                  if (!header) return null;
                  return cells[header.index] || null;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim() }))
                  .filter(item => visible(item.row))
                  .filter(item => {
                    const cell = rowNameCell(item.row);
                    return cell && norm(cell.innerText || cell.textContent) === norm(workshopName);
                  });
                const row = rows[0]?.row;
                if (!row) return false;
                const nodes = Array.from(row.querySelectorAll('button, a, span, [role="button"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                for (const actionText of actionTexts) {
                  const target = nodes.find(item => item.text === actionText) ||
                    nodes.find(item => item.text.includes(actionText));
                  if (target) {
                    const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                    clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
                    clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
                    clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
                    clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            {"workshopName": workshop_name, "actionTexts": action_texts},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def verify_guarantee_trade_amount(self, fulfillment_order_no: str, expected_amount: str) -> str:
        assert fulfillment_order_no.startswith("ORB") and "..." not in fulfillment_order_no, (
            f"关联履约单号必须是完整 ORB 单号；fulfillment_order_no={fulfillment_order_no}"
        )
        print(f"[E2E] 进入企业管理-担保交易详情，准备在担保平台按ORB查询：{fulfillment_order_no}")
        trade_page = self.open_guarantee_trade_detail_page()
        scope = self.guarantee_trade_scope(trade_page)
        filled = (
            self.fill_input_by_labels(scope, ["关联履约单号", "关联履单单号", "关联履约单"], fulfillment_order_no)
            or self.fill_input_near_label(scope, "关联履约单号", fulfillment_order_no)
            or self.fill_input_near_label(scope, "关联履单单号", fulfillment_order_no)
        )
        assert filled, (
            f"担保交易详情未找到关联履约/履单单号输入框；fulfillment_order_no={fulfillment_order_no}; "
            f"text={scope.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if not (self.click_optional_frame_text(scope, "查 询") or self.click_optional_frame_text(scope, "查询")):
            scope.locator("input:visible").first.press("Enter")
        trade_page.wait_for_timeout(2500)
        trade_record = self.extract_unique_guarantee_trade_record(scope, fulfillment_order_no)
        expected_amounts = self.guarantee_trade_expected_amounts(expected_amount)
        print(
            f"[E2E] 担保平台金额校验：ORB={fulfillment_order_no}，"
            f"扣除金额={trade_record['deduction_amount']}，期望={expected_amounts} 的负数"
        )
        assert self.negative_amount_text_matches(trade_record["deduction_amount"], expected_amounts), (
            f"担保交易扣除金额不等于负数发单价；fulfillment_order_no={fulfillment_order_no}; "
            f"expected_amounts={expected_amounts}; deduction_amount={trade_record['deduction_amount']}; "
            f"row_text={trade_record['row_text'][:1500]}"
        )
        return trade_record["row_text"]

    def open_guarantee_trade_detail_page(self) -> Page:
        self.goto_enterprise_console()
        self.wait_for_enterprise_console_shell()
        try:
            self.click_sidebar_text("企业概览")
        except AssertionError:
            assert self.click_page_text("企业概览"), (
                f"未找到企业概览入口；text={self.page.locator('body').inner_text(timeout=5000)[:1500]}"
            )
        self.page.wait_for_timeout(2000)
        before_pages = set(self.page.context.pages)
        clicked = (
            self.click_page_text("担保交易详情 >")
            or self.click_page_text("担保交易详情")
            or self.click_any_frame_text("担保交易详情 >")
            or self.click_any_frame_text("担保交易详情")
        )
        trade_page = self.new_page_after_click(before_pages) or self.page
        if not clicked and trade_page is self.page:
            body_text = self.page.locator("body").inner_text(timeout=5000)
            assert "企业概览" in body_text, f"企业概览未加载完成，无法进入担保交易详情；text={body_text[:1500]}"
        trade_page.wait_for_load_state("domcontentloaded")
        trade_page.wait_for_timeout(2500)
        if not self.guarantee_trade_scope_or_none(trade_page):
            print("[E2E] 未捕获到担保平台新页，直接进入担保平台资金管理页")
            assert_url_allowed_for_env(GUARANTEE_PLATFORM_FUND_URL)
            trade_page.goto(GUARANTEE_PLATFORM_FUND_URL, wait_until="domcontentloaded")
            trade_page.wait_for_timeout(3000)
        return trade_page

    def new_page_after_click(self, before_pages: set[Page], timeout_ms: int = 5000) -> Page | None:
        deadline = datetime.now().timestamp() + timeout_ms / 1000
        while datetime.now().timestamp() < deadline:
            pages = [page for page in self.page.context.pages if page not in before_pages]
            if pages:
                return pages[-1]
            self.page.wait_for_timeout(300)
        return None

    def guarantee_trade_scope(self, trade_page: Page) -> Page | Frame:
        scope = self.guarantee_trade_scope_or_none(trade_page)
        if scope:
            return scope
        raise AssertionError(
            f"担保交易详情页面未找到包含关联履约/履单单号的查询区域；url={trade_page.url}; "
            f"text={trade_page.locator('body').inner_text(timeout=5000)[:1500]}"
        )

    def guarantee_trade_scope_or_none(self, trade_page: Page) -> Page | Frame | None:
        for _ in range(15):
            for scope in [trade_page, *[frame for frame in trade_page.frames if frame.url != "about:blank"]]:
                try:
                    text = scope.locator("body").inner_text(timeout=1500)
                except Exception:
                    continue
                if (
                    ("关联履约单号" in text or "关联履单单号" in text)
                    and ("金额" in text or "资金管理" in text or "担保交易" in text)
                ):
                    return scope
            trade_page.wait_for_timeout(1000)
        return None

    def fill_input_by_labels(self, scope: Page | Frame, label_texts: list[str], value: str) -> bool:
        return bool(
            scope.evaluate(
                """({ labelTexts, value }) => {
                    const normalize = text => (text || '').replace(/\\s+/g, '').replace(/[：:]/g, '').trim();
                    const expected = labelTexts.map(normalize);
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    let input = Array.from(document.querySelectorAll('input:not([disabled]), textarea:not([disabled])'))
                      .find(el => expected.some(text => normalize(el.getAttribute('placeholder') || '').includes(text))) || null;
                    if (!input) {
                      const labels = Array.from(document.querySelectorAll('label, span, div, taro-view-core'))
                        .map(el => ({ el, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                        .filter(item => visible(item.el))
                        .filter(item => expected.some(text => item.text === text || item.text.includes(text)))
                        .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                      const label = labels[0];
                      if (label) {
                        input = Array.from(document.querySelectorAll('input:not([disabled]), textarea:not([disabled])'))
                          .map(el => ({ el, r: el.getBoundingClientRect() }))
                          .filter(item => visible(item.el))
                          .filter(item => Math.abs(item.r.y - label.r.y) < 72 && item.r.x >= label.r.x)
                          .sort((a, b) => Math.abs(a.r.y - label.r.y) - Math.abs(b.r.y - label.r.y) || a.r.x - b.r.x)[0]?.el || null;
                      }
                    }
                    if (!input) return false;
                    input.focus();
                    const proto = input.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
                    const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                    setter.call(input, value);
                    input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    input.dispatchEvent(new Event('change', { bubbles: true }));
                    input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"labelTexts": label_texts, "value": value},
            )
        )

    def extract_unique_guarantee_trade_row_text(self, scope: Page | Frame, fulfillment_order_no: str) -> str:
        return self.extract_unique_guarantee_trade_record(scope, fulfillment_order_no)["row_text"]

    def extract_unique_guarantee_trade_record(self, scope: Page | Frame, fulfillment_order_no: str) -> dict[str, str]:
        matches = list(
            scope.evaluate(
                """fulfillmentOrderNo => {
                    const normalize = text => String(text || '').replace(/\\s+/g, '').trim();
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const headerCells = Array.from(document.querySelectorAll('thead th, .ant-table-thead th, [role="columnheader"]'))
                      .map((el, index) => ({ index, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0);
                    const amountIndex = headerCells.find(item => item.text.includes('扣除金额'))?.index ??
                      headerCells.find(item => item.text.includes('金额(元)'))?.index ??
                      headerCells.find(item => item.text.includes('金额'))?.index ??
                      -1;
                    const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [role="row"], [class*="table"] [class*="row"]'))
                      .map(row => {
                        const cells = Array.from(row.querySelectorAll('td, .ant-table-cell, [role="cell"]'))
                          .map(cell => (cell.innerText || cell.textContent || '').replace(/\\s+/g, ' ').trim());
                        const text = (row.innerText || row.textContent || '').replace(/\\s+/g, ' ').trim();
                        const r = row.getBoundingClientRect();
                        const deductionAmount = amountIndex >= 0 && amountIndex < cells.length ? cells[amountIndex] : '';
                        return { text, deductionAmount, amountIndex, cells, r };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text.includes(fulfillmentOrderNo));
                    return rows.map(item => ({
                      row_text: item.text,
                      deduction_amount: item.deductionAmount,
                      amount_index: String(item.amountIndex),
                      cells: item.cells
                    }));
                }""",
                fulfillment_order_no,
            )
        )
        assert len(matches) == 1, (
            f"担保交易详情未按关联履约单号精确匹配到唯一记录；"
            f"fulfillment_order_no={fulfillment_order_no}; match_count={len(matches)}; matches={matches[:5]}"
        )
        record = dict(matches[0])
        assert record.get("deduction_amount"), (
            f"担保交易详情唯一记录未提取到扣除金额/金额(元)列；"
            f"fulfillment_order_no={fulfillment_order_no}; record={record}"
        )
        return {
            "row_text": str(record.get("row_text", "")),
            "deduction_amount": str(record.get("deduction_amount", "")),
        }

    @staticmethod
    @staticmethod
    def guarantee_trade_expected_amounts(expected_amount: str) -> list[str]:
        configured = os.getenv("E2E_GUARANTEE_EXPECTED_DEDUCTION_AMOUNTS", "").strip()
        if configured:
            return [item.strip() for item in re.split(r"[,，;；\s]+", configured) if item.strip()]
        amounts = [str(expected_amount)]
        if str(expected_amount).strip() == "70":
            amounts.append("72")
        return amounts

    def negative_amount_text_matches(self, text: str, expected_amount: str | list[str]) -> bool:
        expected_amounts = [expected_amount] if isinstance(expected_amount, str) else list(expected_amount)
        return any(self.single_negative_amount_text_matches(text, amount) for amount in expected_amounts)

    @staticmethod
    def single_negative_amount_text_matches(text: str, expected_amount: str) -> bool:
        try:
            expected = -abs(Decimal(str(expected_amount).replace(",", "").replace("¥", "").replace("￥", ""))).quantize(
                Decimal("0.01")
            )
        except InvalidOperation:
            return False
        normalized = (
            text.replace(",", "")
            .replace("\u2212", "-")
            .replace("\u2013", "-")
            .replace("\u2014", "-")
            .replace("－", "-")
            .replace("﹣", "-")
        )
        for match in re.finditer(r"(?<!\d)([-+])\s*[¥￥]?\s*(\d+(?:\.\d{1,2})?)(?!\d)", normalized):
            try:
                value = Decimal(f"{match.group(1)}{match.group(2)}").quantize(Decimal("0.01"))
            except InvalidOperation:
                continue
            if value == expected:
                return True
        amount = f"{abs(expected):.2f}"
        return any(candidate in normalized for candidate in [f"-{amount}", f"-¥{amount}", f"-￥{amount}", f"¥-{amount}", f"￥-{amount}"])

    def click_page_text(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('button, a, span, div, taro-view-core'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.value === text || item.value.includes(text))
                  .sort((a, b) => {
                    const aExact = a.value === text ? 0 : 1;
                    const bExact = b.value === text ? 0 : 1;
                    if (aExact !== bExact) return aExact - bExact;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0]?.el;
                if (!target) return false;
                const clickable = target.closest('button, a, [role="button"]') || target;
                clickable.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_any_frame_text(self, text: str) -> bool:
        for frame in self.page.frames:
            if frame.url == "about:blank":
                continue
            try:
                if self.click_optional_frame_text(frame, text):
                    return True
            except Exception:
                continue
        return False

    def create_manual_order(self, order: ManualOrderData) -> None:
        frame = self.open_order_management()
        self.click_frame_button(frame, "手工录单")
        self.page.wait_for_timeout(2000)
        self.fill_manual_order_form(frame, order)
        self.assert_sub_order_price_mode_ready(frame, order)
        self.click_frame_button(frame, "提交订单")
        self.page.wait_for_timeout(4000)
        self.record_order_submit_errors(order)
        if self.has_visible_modal(frame):
            self.fill_required_fields_after_submit(frame, order)
            self.assert_sub_order_price_mode_ready(frame, order)
            self.click_frame_button(frame, "提交订单")
            self.page.wait_for_timeout(4000)
            self.record_order_submit_errors(order)
        text = self.wait_for_manual_order_visible(frame, order)
        assert order.source_order_no in text or order.order_title in text, (
            f"Wanzhanggui order was not visible after submit; source_order_no={order.source_order_no}; "
            f"order_title={order.order_title}; page_text={text[:1500]}; "
            f"modal_fields={self.modal_field_values(frame)}; "
            f"validation_messages={self.modal_validation_messages(frame)}; "
            f"required_fields={self.modal_required_field_status(frame)}; "
            f"submit_buttons={self.submit_button_status(frame)}"
        )

    def create_manual_order_without_dispatch(self, order: ManualOrderData) -> str:
        frame = self.open_order_management()
        self.click_frame_button(frame, "手工录单")
        self.page.wait_for_timeout(2000)
        self.fill_manual_order_base_form(frame, order)
        self.ensure_no_push_radio(frame)
        self.assert_no_dispatch_fields_visible(frame)
        self.click_frame_button(frame, "提交订单")
        self.page.wait_for_timeout(4000)
        self.record_order_submit_errors(order)
        if self.has_visible_modal(frame):
            self.fill_required_fields_after_submit_without_dispatch(frame, order)
            self.ensure_no_push_radio(frame)
            self.assert_no_dispatch_fields_visible(frame)
            self.click_frame_button(frame, "提交订单")
            self.page.wait_for_timeout(4000)
            self.record_order_submit_errors(order)
        text = self.wait_for_manual_order_visible(frame, order)
        assert order.source_order_no in text or order.order_title in text, (
            f"Wanzhanggui order was not visible after no-dispatch submit; "
            f"source_order_no={order.source_order_no}; order_title={order.order_title}; "
            f"page_text={text[:1500]}; modal_fields={self.modal_field_values(frame)}; "
            f"validation_messages={self.modal_validation_messages(frame)}; "
            f"required_fields={self.modal_required_field_status(frame)}; "
            f"submit_buttons={self.submit_button_status(frame)}"
        )
        self.assert_order_in_pending_accept_status(frame, order)
        return text

    def extract_main_order_no(self, text: str, order: ManualOrderData) -> str:
        if order.main_order_no:
            return order.main_order_no
        marker_index = -1
        for marker in [order.source_order_no, order.order_title]:
            if marker:
                marker_index = text.find(marker)
                if marker_index >= 0:
                    break
        search_text = text if marker_index < 0 else text[max(0, marker_index - 500) : marker_index + 500]
        match = re.search(r"BO\d{20}", search_text)
        if match:
            return match.group(0)
        try:
            return self.find_main_order_no_from_order_list(
                source_order_no=order.source_order_no,
                order_title=order.order_title,
            )
        except AssertionError:
            return ""

    def wait_for_manual_order_visible(self, frame: Frame, order: ManualOrderData) -> str:
        last_text = ""
        for attempt in range(8):
            self.search_order_by_best_identity(
                frame,
                main_order_no=order.main_order_no,
                source_order_no=order.source_order_no,
                order_title=order.order_title,
            )
            self.page.wait_for_timeout(1500)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if order.source_order_no in last_text or order.order_title in last_text:
                return last_text
            if frame.locator(".ant-modal:visible").count():
                self.record_order_submit_errors(order)
        return last_text

    def refresh_order_list_and_get_row_text(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_order_management()
        last_text = ""
        for attempt in range(5):
            self.search_order_by_best_identity(frame, source_order_no=source_order_no, order_title=order_title)
            self.page.wait_for_timeout(1500)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if source_order_no in last_text or order_title in last_text:
                return self.extract_order_list_row_text(frame, source_order_no, order_title)
            if attempt == 2 and order_title:
                self.search_order_by_best_identity(frame, order_title=order_title)
                self.page.wait_for_timeout(1500)
                last_text = frame.locator("body").inner_text(timeout=5000)
                if source_order_no in last_text or order_title in last_text:
                    return self.extract_order_list_row_text(frame, source_order_no, order_title)
            self.click_optional_frame_text(frame, "刷 新") or self.click_optional_frame_text(frame, "刷新")
            self.page.wait_for_timeout(1500)
        raise AssertionError(
            f"丸掌柜订单列表刷新后未找到订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={last_text[:1500]}"
        )

    def assert_order_list_status(
        self,
        source_order_no: str,
        order_title: str,
        expected_statuses: list[str],
    ) -> str:
        row_text = self.refresh_order_list_and_get_row_text(source_order_no, order_title)
        assert any(status in row_text for status in expected_statuses), (
            f"丸掌柜订单列表状态不符合预期；source_order_no={source_order_no}; "
            f"expected_statuses={expected_statuses}; row_text={row_text[:1500]}"
        )
        return row_text

    def extract_order_list_row_text(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        return str(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const candidates = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'));
                    const row = candidates.find(item => {
                      const text = item.innerText || item.textContent || '';
                      return text.includes(sourceOrderNo) || text.includes(orderTitle);
                    });
                    if (row) return row.innerText || row.textContent || '';
                    return document.body.innerText || '';
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )

    def extract_fulfillment_order_no_from_wanzhanggui_order(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(frame, source_order_no=source_order_no, order_title=order_title)
        self.page.wait_for_timeout(1500)
        return self.extract_fulfillment_order_no_from_current_wanzhanggui_order_list(frame, source_order_no, order_title)

    def extract_fulfillment_order_no_from_current_wanzhanggui_order_list(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
    ) -> str:
        list_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in list_text or order_title in list_text, (
            f"丸掌柜订单列表未找到担保单，无法提取关联履约单号；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={list_text[:1500]}"
        )
        row_text = self.extract_order_list_row_text(frame, source_order_no, order_title)
        assert "代练中" in row_text, (
            f"3.1 获取ORB前置不满足：丸掌柜担保单搜索结果不是代练中；"
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}"
        )

        opened = self.open_order_detail_by_row_action(frame, source_order_no, order_title)
        assert opened, (
            f"丸掌柜订单列表未能点击担保单详情，无法提取关联履约单号；"
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}"
        )
        detail_text = self.wait_for_order_detail_orb_text(frame, source_order_no)
        fulfillment_order_no = self.extract_orb_order_no(detail_text)
        assert fulfillment_order_no, (
            f"丸掌柜订单详情未提取到 ORB 开头的关联履约单号；"
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        return fulfillment_order_no

    def wait_for_order_detail_orb_text(self, frame: Frame, source_order_no: str) -> str:
        last_text = ""
        for _ in range(8):
            self.page.wait_for_timeout(1000)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if "ORB" in last_text and ("订单详情" in last_text or "子单列表" in last_text or source_order_no in last_text):
                return last_text
        return last_text

    def open_order_detail_by_row_action(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        opened = self.click_order_row_main_order_link(frame, source_order_no, order_title)
        if opened:
            self.page.wait_for_timeout(1800)
            return True
        opened = self.click_order_row_action(frame, source_order_no, order_title, ["详情", "查看详情", "订单详情", "查看订单"])
        if opened:
            self.page.wait_for_timeout(1800)
            return True
        opened = self.click_current_filtered_order_detail_action(frame, source_order_no, order_title)
        if opened:
            self.page.wait_for_timeout(1800)
            return True
        self.click_order_row_action(frame, source_order_no, order_title, ["更多", "...", "操作"])
        self.page.wait_for_timeout(800)
        opened = self.click_visible_action_text(frame, ["详情", "查看详情", "订单详情", "查看订单"])
        if not opened:
            opened = self.click_last_target_order_action(frame, source_order_no, order_title)
        self.page.wait_for_timeout(1800)
        return opened

    def click_order_row_main_order_link(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        return bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const textOf = el => (el.innerText || el.textContent || '').trim();
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: textOf(row), r: row.getBoundingClientRect() }))
                      .filter(item => visible(item.row))
                      .filter(item =>
                        (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                        (orderTitle && item.text.includes(orderTitle))
                      );
                    const row = rows[0]?.row;
                    if (!row) return false;
                    const firstCell = row.querySelector('td, .ant-table-cell, [role="cell"], [class*="cell"]') || row;
                    const candidates = Array.from(firstCell.querySelectorAll('a, button, [role="button"], span, div'))
                      .map(el => ({ el, text: textOf(el), r: el.getBoundingClientRect(), cls: String(el.className || '') }))
                      .filter(item => visible(item.el))
                      .filter(item => item.text.includes('主单号') || /BO\\d{6,}/.test(item.text))
                      .sort((a, b) => a.r.x - b.r.x || a.r.y - b.r.y || a.text.length - b.text.length);
                    const target = candidates[0];
                    if (!target) return false;
                    const clickable = target.el.closest('a, button, [role="button"], .ant-btn, [class*="link"]') || target.el;
                    const r = clickable.getBoundingClientRect();
                    const x = r.left + Math.min(Math.max(18, r.width * 0.22), Math.max(18, r.width - 8));
                    const y = r.top + r.height / 2;
                    const pointTarget = document.elementFromPoint(x, y) || clickable;
                    pointTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.click();
                    if (pointTarget !== clickable) clickable.click();
                    return true;
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )

    def click_current_filtered_order_detail_action(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        return bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const textOf = el => (el.innerText || el.textContent || '').trim();
                    const bodyText = textOf(document.body);
                    if (!bodyText.includes(sourceOrderNo) && !bodyText.includes(orderTitle)) return false;
                    const actionTexts = ['查看详情', '订单详情', '详情'];
                    const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], span, div'))
                      .map(el => ({
                        el,
                        text: textOf(el),
                        r: el.getBoundingClientRect(),
                        tag: el.tagName.toLowerCase(),
                        role: el.getAttribute('role') || '',
                        cls: String(el.className || '')
                      }))
                      .filter(item => visible(item.el))
                      .filter(item => actionTexts.some(action => item.text === action || item.text.includes(action)))
                      .filter(item => {
                        let node = item.el;
                        for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) {
                          const text = textOf(node);
                          if ((sourceOrderNo && text.includes(sourceOrderNo)) || (orderTitle && text.includes(orderTitle))) {
                            return true;
                          }
                        }
                        return true;
                      })
                      .sort((a, b) => {
                        const score = item => {
                          if (item.text === '查看详情') return 0;
                          if (item.text === '订单详情') return 1;
                          if (item.text === '详情') return 2;
                          if (item.tag === 'button' || item.tag === 'a' || item.role === 'button') return 3;
                          return 4;
                        };
                        return score(a) - score(b) || a.text.length - b.text.length || a.r.y - b.r.y || a.r.x - b.r.x;
                      });
                    const target = nodes[0];
                    if (!target) return false;
                    const clickable = target.el.closest('button, a, [role="button"], .ant-btn, [class*="link"]') || target.el;
                    const r = clickable.getBoundingClientRect();
                    clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.click();
                    return true;
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )

    def click_last_target_order_action(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        return bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const textOf = el => (el.innerText || el.textContent || '').trim();
                    const containers = Array.from(document.querySelectorAll(
                      'tr, .ant-table-row, [class*="table"] [class*="row"], [class*="card"], [class*="item"], [class*="list"] > div'
                    ))
                      .map(el => ({ el, text: textOf(el), r: el.getBoundingClientRect() }))
                      .filter(item => visible(item.el))
                      .filter(item =>
                        (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                        (orderTitle && item.text.includes(orderTitle))
                      )
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                    const row = containers[0]?.el;
                    if (!row) return false;
                    const actionCandidates = Array.from(row.querySelectorAll('button, a, [role="button"], span, div'))
                      .map(el => ({
                        el,
                        text: textOf(el).replace(/\\s+/g, ''),
                        r: el.getBoundingClientRect(),
                        tag: el.tagName.toLowerCase(),
                        role: el.getAttribute('role') || '',
                        cls: String(el.className || '')
                      }))
                      .filter(item => visible(item.el))
                      .filter(item => item.text && item.text.length <= 12)
                      .filter(item => !/取消|编辑|备注|售后|撤销|删除|复制|刷新/.test(item.text))
                      .filter(item =>
                        /详情|查看|订单/.test(item.text) ||
                        item.tag === 'button' ||
                        item.tag === 'a' ||
                        item.role === 'button' ||
                        item.cls.includes('btn') ||
                        item.cls.includes('link')
                      )
                      .sort((a, b) => {
                        const score = item => {
                          if (/查看详情|订单详情|详情|查看订单/.test(item.text)) return 0;
                          if (/查看|订单/.test(item.text)) return 1;
                          return 2;
                        };
                        return score(a) - score(b) || b.r.x - a.r.x || b.r.y - a.r.y;
                      });
                    const target = actionCandidates[0];
                    if (!target) return false;
                    const clickable = target.el.closest('button, a, [role="button"], .ant-btn, [class*="link"]') || target.el;
                    const r = clickable.getBoundingClientRect();
                    clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.click();
                    return true;
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )

    @staticmethod
    def extract_orb_order_no(text: str) -> str:
        patterns = [
            r"关联履约单号[:：\s]*([A-Z]{2,}\d{8,})",
            r"关联履单单号[:：\s]*([A-Z]{2,}\d{8,})",
            r"履约单号[:：\s]*([A-Z]{2,}\d{8,})",
            r"\b(ORB\d{8,})\b",
        ]
        for pattern in patterns:
            match = re.search(pattern, text or "")
            if match:
                value = match.group(1).strip()
                if value.startswith("ORB") and "..." not in value:
                    return value
        return ""

    def revoke_dispatched_order(self, source_order_no: str, order_title: str) -> None:
        frame = self.open_order_management()
        self.search_order_if_possible(frame, source_order_no)
        text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in text or order_title in text, (
            f"Wanzhanggui order list did not find order to revoke; source_order_no={source_order_no}; "
            f"order_title={order_title}; page_text={text[:1500]}"
        )

        clicked = self.click_order_row_action(
            frame,
            source_order_no,
            order_title,
            ["撤销派单", "撤销发单", "取消派单", "撤销"],
        )
        if not clicked:
            self.click_order_row_action(frame, source_order_no, order_title, ["鏇村", "...", "鎿嶄綔"])
            self.page.wait_for_timeout(800)
            clicked = self.click_visible_action_text(frame, ["撤销派单", "撤销发单", "取消派单", "撤销"])
        assert clicked, (
            f"Wanzhanggui order list did not find revoke action; source_order_no={source_order_no}; "
            f"order_title={order_title}; page_text={text[:1500]}"
        )

        self.page.wait_for_timeout(1000)
        self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3000)
        self.record_revoke_errors(source_order_no, order_title)
        self.search_order_if_possible(frame, source_order_no)
        text_after_revoke = frame.locator("body").inner_text(timeout=5000)
        still_pending = source_order_no in text_after_revoke or order_title in text_after_revoke
        assert not still_pending, (
            f"Wanzhanggui order still visible after revoke; source_order_no={source_order_no}; "
            f"order_title={order_title}; page_text={text_after_revoke[:1500]}"
        )

    def revoke_current_sub_order_keep_main(
        self,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
        reason: str = "",
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待撤销子单的主单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        action_texts = ["撤销", "发起撤销", "撤销派单", "撤销发单", "取消派单", "撤销子单"]
        clicked = self.click_order_row_action(frame, source_order_no, order_title, action_texts)
        if not clicked:
            opened = self.open_main_order_detail_from_list(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            assert opened, (
                f"丸掌柜订单管理未能打开待撤销子单主单详情；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; text={before_text[:1500]}"
            )
            clicked = self.click_sub_order_list_action_by_status(frame, ["已发布"], action_texts)
            if not clicked:
                clicked = self.click_wanzhanggui_detail_action_text(frame, action_texts)
        assert clicked, (
            f"丸掌柜未找到子单撤销入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        if self.visible_modal_text(frame):
            self.fill_visible_modal_inputs_if_present(frame, reason or f"E2E撤销子单 {main_order_no or source_order_no}")
            self.submit_current_modal(frame, ["确认撤销", "提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)

        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in final_text
            or order_title in final_text
            or (main_order_no and main_order_no in final_text)
            or self.filtered_order_list_has_single_result(final_text)
        ), (
            f"撤销子单后主单不应从丸掌柜订单列表消失；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def click_sub_order_list_action_by_status(
        self,
        frame: Frame,
        statuses: list[str],
        action_texts: list[str],
    ) -> bool:
        script = """({ statuses, actionTexts }) => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
              .filter(visible)
              .filter(root => (root.innerText || root.textContent || '').includes('子单列表'));
            const root = roots.pop();
            if (!root) return false;
            const modalText = root.innerText || root.textContent || '';
            if (!statuses.some(status => modalText.includes(status))) {
              return false;
            }

            const controls = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
              .map(el => {
                const clickable = el.closest('button, a, [role="button"]') || el;
                const r = clickable.getBoundingClientRect();
                return {
                  el: clickable,
                  text: (el.innerText || el.textContent || '').trim(),
                  r,
                  disabled: Boolean(clickable.disabled)
                };
              })
              .filter(item => item.r.width > 0 && item.r.height > 0 && item.text && !item.disabled);
            for (const actionText of actionTexts) {
              const target = controls.find(item => item.text === actionText) ||
                controls.find(item => item.text.includes(actionText));
              if (!target) continue;
              const x = target.r.left + target.r.width / 2;
              const y = target.r.top + target.r.height / 2;
              target.el.scrollIntoView({ block: 'center', inline: 'center' });
              target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
              target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
              target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
              target.el.click();
              return true;
            }
            return false;
        }"""
        clicked = bool(frame.evaluate(script, {"statuses": statuses, "actionTexts": action_texts}))
        if not clicked:
            try:
                clicked = bool(self.page.evaluate(script, {"statuses": statuses, "actionTexts": action_texts}))
            except Exception:
                clicked = False
        if not clicked:
            clicked = self.click_detail_link_button_by_text(frame, statuses, action_texts)
        self.page.wait_for_timeout(800)
        return clicked

    def click_sub_order_row_action_by_status(
        self,
        frame: Frame,
        statuses: list[str],
        action_texts: list[str],
    ) -> bool:
        clicked = bool(
            frame.evaluate(
                """({ statuses, actionTexts }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                      .filter(visible)
                      .filter(root => (root.innerText || root.textContent || '').includes('子单列表'));
                    const root = roots.pop();
                    if (!root) return false;
                    const rows = Array.from(root.querySelectorAll('tr, .ant-table-row'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => statuses.some(status => item.text.includes(status)));
                    for (const item of rows) {
                      const controls = Array.from(item.row.querySelectorAll('button, a, [role="button"], span'))
                        .map(el => {
                          const clickable = el.closest('button, a, [role="button"]') || el;
                          return {
                            el: clickable,
                            text: (el.innerText || el.textContent || '').trim(),
                            r: clickable.getBoundingClientRect(),
                            disabled: Boolean(clickable.disabled)
                          };
                        })
                        .filter(node => node.r.width > 0 && node.r.height > 0 && node.text && !node.disabled);
                      for (const actionText of actionTexts) {
                        const target = controls.find(node => node.text === actionText) ||
                          controls.find(node => node.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, '')) ||
                          controls.find(node => node.text.includes(actionText));
                        if (!target) continue;
                        target.el.scrollIntoView({ block: 'center', inline: 'center' });
                        const r = target.r;
                        target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        target.el.click();
                        return true;
                      }
                    }
                    return false;
                }""",
                {"statuses": statuses, "actionTexts": action_texts},
            )
        )
        if not clicked:
            clicked = self.click_detail_link_button_by_text(frame, statuses, action_texts)
        self.page.wait_for_timeout(800)
        return clicked

    def click_detail_link_button_by_text(
        self,
        frame: Frame,
        statuses: list[str],
        action_texts: list[str],
    ) -> bool:
        modal_text = self.visible_modal_text(frame)
        if not any(status in modal_text for status in statuses):
            return False
        for action_text in action_texts:
            selectors = [
                f".ant-modal button.ant-btn-link:has-text('{action_text}')",
                f".ant-modal button:has(span:text-is('{action_text}'))",
                f"button.ant-btn-link:has-text('{action_text}')",
                f"button:has(span:text-is('{action_text}'))",
            ]
            for selector in selectors:
                target = frame.locator(selector).last
                if target.count():
                    target.click(force=True)
                    self.page.wait_for_timeout(800)
                    return True
                target = self.page.locator(selector).last
                if target.count():
                    target.click(force=True)
                    self.page.wait_for_timeout(800)
                    return True
        return False

    def complete_upstream_order(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_order_management()
        self.search_order_if_possible(frame, source_order_no)
        text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in text or order_title in text, (
            f"丸掌柜订单管理未找到待完结主单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        clicked = self.click_order_row_action(
            frame,
            source_order_no,
            order_title,
            ["完结主单", "确认验收", "验收通过", "完成订单", "完结", "通过"],
        )
        assert clicked, (
            f"丸掌柜订单管理未找到完结/验收操作；source_order_no={source_order_no}; "
            f"text={text[:1500]}"
        )
        self.page.wait_for_timeout(1000)
        self.confirm_open_dialog(frame)
        self.fill_modal_textarea_if_present(frame, f"E2E完结主单 {source_order_no}")
        self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(4000)
        text_after_confirm = frame.locator("body").inner_text(timeout=5000)
        if self.has_unfinished_sub_order_notice(text_after_confirm):
            return text_after_confirm
        self.search_order_if_possible(frame, source_order_no)
        return frame.locator("body").inner_text(timeout=5000)

    def search_order_by_best_identity(
        self,
        frame: Frame,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
    ) -> str:
        if main_order_no and main_order_no.startswith("BO"):
            if self.search_order_by_labels(frame, main_order_no, ["上游单号", "主单号", "订单号", "订单编号"]):
                return
            self.search_order_if_possible(frame, main_order_no)
            return
        if source_order_no:
            if self.search_order_by_labels(frame, source_order_no, ["来源单号", "业务单号"]):
                return
            self.search_order_if_possible(frame, source_order_no)
            return
        if order_title:
            if self.search_order_by_labels(frame, order_title, ["订单标题"]):
                return
            self.search_order_if_possible(frame, order_title)

    def search_order_by_labels(self, frame: Frame, keyword: str, labels: list[str]) -> bool:
        for label in labels:
            filled = self.fill_filter_input_by_label(frame, label, keyword) or self.fill_input_near_label(frame, label, keyword)
            if filled:
                self.click_query_button(frame)
                self.page.wait_for_timeout(2000)
                return True
        return False

    def fill_filter_input_by_label(self, frame: Frame, label_text: str, value: str) -> bool:
        return bool(
            frame.evaluate(
                """({ labelText, value }) => {
                    const normalize = text => String(text || '').replace(/\\s+/g, '');
                    const setValue = input => {
                      input.focus();
                      const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                      setter.call(input, value);
                      input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                      input.dispatchEvent(new Event('change', { bubbles: true }));
                      input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                      return true;
                    };
                    const targetLabel = normalize(labelText);
                    const roots = Array.from(document.querySelectorAll('.ant-form-item, [class*="form-item"], .ant-row, [class*="search"], [class*="filter"], div'))
                      .map(el => ({ el, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.r.y < 420)
                      .filter(item => item.text.includes(targetLabel))
                      .sort((a, b) => {
                        const aInput = a.el.querySelectorAll('input').length ? 0 : 1;
                        const bInput = b.el.querySelectorAll('input').length ? 0 : 1;
                        return aInput - bInput || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                      });
                    for (const root of roots) {
                      const inputs = Array.from(root.el.querySelectorAll('input'))
                        .filter(input => {
                          const r = input.getBoundingClientRect();
                          return r.width > 0 && r.height > 0 && !input.disabled && input.getAttribute('type') !== 'hidden';
                        });
                      if (inputs.length) return setValue(inputs[0]);
                    }
                    const labels = Array.from(document.querySelectorAll('label, span, div'))
                      .map(el => ({ el, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && item.r.y < 420)
                      .filter(item => item.text === targetLabel || item.text.includes(targetLabel))
                      .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                    const label = labels[0];
                    if (!label) return false;
                    const inputs = Array.from(document.querySelectorAll('input'))
                      .map(el => ({ el, r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => !item.el.disabled && item.el.getAttribute('type') !== 'hidden')
                      .filter(item => item.r.y >= label.r.y - 16 && item.r.y <= label.r.y + 56 && item.r.x >= label.r.x)
                      .sort((a, b) => Math.abs(a.r.y - label.r.y) - Math.abs(b.r.y - label.r.y) || a.r.x - b.r.x);
                    const input = inputs[0]?.el;
                    return input ? setValue(input) : false;
                }""",
                {"labelText": label_text, "value": value},
            )
        )

    def click_query_button(self, frame: Frame) -> bool:
        clicked = bool(
            frame.evaluate(
                """() => {
                    const buttons = Array.from(document.querySelectorAll('button'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                    const target = buttons.find(item => item.text === '查 询' || item.text === '查询') ||
                      buttons.find(item => item.text.replace(/\\s+/g, '') === '查询');
                    if (!target) return false;
                    target.el.scrollIntoView({ block: 'center', inline: 'center' });
                    target.el.click();
                    return true;
                }"""
            )
        )
        if not clicked:
            clicked = self.click_optional_frame_text(frame, "查 询") or self.click_optional_frame_text(frame, "查询")
        self.page.wait_for_timeout(500)
        return bool(clicked)

    def assert_main_order_appeal_record_visible(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str = "",
        amount: str = "",
    ) -> str:
        frame = self.open_main_order_appeal_list()
        if main_order_no and main_order_no.startswith("BO"):
            searched = self.search_order_by_labels(frame, main_order_no, ["主单号", "订单号", "订单编号"])
        elif source_order_no:
            searched = self.search_order_by_labels(frame, source_order_no, ["来源单号", "业务单号", "订单号"])
        elif order_title:
            searched = self.search_order_by_labels(frame, order_title, ["订单标题"])
        else:
            searched = False
        if not searched:
            self.search_order_if_possible(frame, main_order_no or source_order_no or order_title)

        text = frame.locator("body").inner_text(timeout=5000)
        identity_found = (
            (main_order_no and main_order_no in text)
            or (source_order_no and source_order_no in text)
            or (order_title and order_title in text)
        )
        assert identity_found, (
            f"丸掌柜主单申诉单未找到目标申诉记录；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={text[:1500]}"
        )
        assert "申诉" in text, (
            f"丸掌柜主单申诉单目标记录缺少申诉信息；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={text[:1500]}"
        )
        if amount:
            assert amount in text or f"{amount}.00" in text, (
                f"丸掌柜主单申诉单未看到申诉金额；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; amount={amount}; text={text[:1500]}"
            )
        if reason:
            compact_reason = reason.replace(" ", "")
            compact_text = text.replace(" ", "")
            assert reason in text or compact_reason[:30] in compact_text, (
                f"丸掌柜主单申诉单未看到申诉原因；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; reason={reason}; text={text[:1500]}"
            )
        return text

    def assert_sub_order_penalty_record_visible(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        expected_status_keywords: list[str] | None = None,
    ) -> str:
        frame = self.open_sub_order_penalty_list()
        searched = False
        if main_order_no and main_order_no.startswith("BO"):
            searched = self.search_order_by_labels(frame, main_order_no, ["主单号", "订单号", "订单编号", "上游单号"])
        if not searched and source_order_no:
            searched = self.search_order_by_labels(frame, source_order_no, ["来源单号", "业务单号", "订单号", "上游单号"])
        if not searched and order_title:
            searched = self.search_order_by_labels(frame, order_title, ["订单标题"])
        if not searched:
            self.search_order_if_possible(frame, main_order_no or source_order_no or order_title)

        text = frame.locator("body").inner_text(timeout=5000)
        identity_found = (
            (main_order_no and main_order_no in text)
            or (source_order_no and source_order_no in text)
            or (order_title and order_title in text)
            or self.filtered_order_list_has_single_result(text)
        )
        assert identity_found, (
            f"丸掌柜子单处罚单未找到目标处罚记录；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={text[:1500]}"
        )
        assert "处罚" in text or "申诉" in text or "赔偿" in text, (
            f"丸掌柜子单处罚单目标记录缺少处罚/申诉信息；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={text[:1500]}"
        )
        if expected_status_keywords:
            assert any(keyword in text for keyword in expected_status_keywords), (
                f"丸掌柜子单处罚单状态不符合预期；expected={expected_status_keywords}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; text={text[:1500]}"
            )
        return text

    def assert_sub_order_penalty_result(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        expected_status: str,
        expected_apply_amount: str,
        expected_penalty_amount: str,
        order_title: str = "",
    ) -> str:
        text = self.assert_sub_order_penalty_record_visible(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_status_keywords=[expected_status],
        )
        row_text = self.table_row_text_by_keywords(
            self.order_frame(),
            [main_order_no, source_order_no, expected_status],
        )
        assert row_text, (
            f"丸掌柜子单处罚单未找到目标状态行；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; expected_status={expected_status}; text={text[:1500]}"
        )
        compact = re.sub(r"\s+", "", row_text)
        assert expected_status in compact, (
            f"丸掌柜子单处罚单目标行状态不符合预期；expected={expected_status}; row={row_text[:1500]}"
        )
        assert self.amount_pair_in_text(compact, expected_apply_amount, expected_penalty_amount), (
            f"丸掌柜子单处罚单目标行金额不符合预期；"
            f"expected_apply={expected_apply_amount}; expected_penalty={expected_penalty_amount}; row={row_text[:1500]}"
        )
        return text

    def approve_main_order_appeal(
        self,
        *,
        main_order_no: str,
        source_order_no: str = "",
        order_title: str = "",
        compensation_amount: str = "1",
        remark: str = "S-FLOW-052 主单申诉审核通过",
    ) -> str:
        assert main_order_no and main_order_no.startswith("BO"), (
            f"S-FLOW-052 需要 BO 开头的主单号用于主单申诉单审核；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}"
        )
        frame = self.open_main_order_appeal_list()
        self.search_order_by_labels(frame, main_order_no, ["订单号", "主单号", "订单编号"])
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert main_order_no in before_text, (
            f"丸掌柜主单申诉单未找到待审核记录；main_order_no={main_order_no}; text={before_text[:1500]}"
        )
        target_before = self.table_row_text_by_keyword(frame, main_order_no) or self.order_text_after_keyword(before_text, main_order_no)
        assert "处理中" in target_before or "待处理" in target_before or "去处理" in target_before, (
            f"S-FLOW-052 前置状态不满足主单申诉待处理；main_order_no={main_order_no}; "
            f"text={before_text[:1500]}"
        )

        clicked = self.click_table_row_action_by_keyword(frame, main_order_no, ["去处理", "处理", "审核"])
        if not clicked:
            clicked = self.click_order_row_action(frame, main_order_no, main_order_no, ["去处理", "处理", "审核"])
        if not clicked:
            clicked = self.click_visible_action_text(frame, ["去处理", "处理", "审核"])
        assert clicked, (
            f"丸掌柜主单申诉单未找到去处理入口；main_order_no={main_order_no}; text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)

        modal_text = self.visible_modal_text(frame)
        if modal_text:
            self.click_modal_radio_text(frame, "同意") or self.click_modal_radio_text(frame, "通过") or self.click_modal_radio_text(frame, "确认赔偿")
            self.fill_modal_amount_if_present(frame, compensation_amount)
            self.fill_visible_modal_inputs_if_present(frame, remark)
            self.submit_current_modal(frame, ["确认赔偿", "审核通过", "通过", "同意", "提交", "确定", "确认"])
        else:
            clicked = self.click_wanzhanggui_detail_action_text(frame, ["确认赔偿", "审核通过", "通过", "同意"])
            assert clicked, (
                f"丸掌柜主单申诉处理详情未找到审核通过/确认赔偿入口；main_order_no={main_order_no}; "
                f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
            self.page.wait_for_timeout(1000)
            self.fill_modal_amount_if_present(frame, compensation_amount)
            self.fill_visible_modal_inputs_if_present(frame, remark)
            self.submit_current_modal(frame, ["确认赔偿", "审核通过", "通过", "同意", "提交", "确定", "确认"])

        self.page.wait_for_timeout(3500)
        self.search_order_by_labels(frame, main_order_no, ["订单号", "主单号", "订单编号"])
        final_text = frame.locator("body").inner_text(timeout=5000)
        target_text = self.table_row_text_by_keyword(frame, main_order_no) or self.order_text_after_keyword(final_text, main_order_no)
        assert any(status in target_text for status in ["已完成", "完成", "已处理", "通过"]), (
            f"主单申诉审核通过后未看到完成/通过状态；main_order_no={main_order_no}; text={final_text[:1500]}"
        )
        assert "处理中" not in target_text and "待处理" not in target_text, (
            f"主单申诉审核通过后目标记录仍显示待处理；main_order_no={main_order_no}; text={final_text[:1500]}"
        )
        if compensation_amount:
            assert compensation_amount in target_text or f"{compensation_amount}.00" in target_text, (
                f"主单申诉审核通过后未看到赔付金额；main_order_no={main_order_no}; "
                f"amount={compensation_amount}; text={final_text[:1500]}"
            )
        return final_text

    def reject_main_order_appeal(
        self,
        *,
        main_order_no: str,
        source_order_no: str = "",
        order_title: str = "",
        reject_reason: str = "S-FLOW-053 主单申诉审核驳回",
    ) -> str:
        assert main_order_no and main_order_no.startswith("BO"), (
            f"S-FLOW-053 需要 BO 开头的主单号用于主单申诉单审核；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}"
        )
        frame = self.open_main_order_appeal_list()
        self.search_order_by_labels(frame, main_order_no, ["订单号", "主单号", "订单编号"])
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert main_order_no in before_text, (
            f"丸掌柜主单申诉单未找到待驳回记录；main_order_no={main_order_no}; text={before_text[:1500]}"
        )
        target_before = self.table_row_text_by_keyword(frame, main_order_no) or self.order_text_after_keyword(before_text, main_order_no)
        if any(status in target_before for status in ["已完成", "完成", "已处理", "已拒绝"]) and any(
            marker in target_before for marker in ["不同意", "驳回", "拒绝"]
        ):
            return before_text
        assert "处理中" in target_before or "待处理" in target_before or "去处理" in target_before, (
            f"S-FLOW-053 前置状态不满足主单申诉待处理；main_order_no={main_order_no}; "
            f"text={before_text[:1500]}"
        )

        clicked = self.click_table_row_action_by_keyword(frame, main_order_no, ["去处理", "处理", "审核"])
        if not clicked:
            clicked = self.click_order_row_action(frame, main_order_no, main_order_no, ["去处理", "处理", "审核"])
        if not clicked:
            clicked = self.click_visible_action_text(frame, ["去处理", "处理", "审核"])
        assert clicked, (
            f"丸掌柜主单申诉单未找到去处理入口；main_order_no={main_order_no}; text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)

        modal_text = self.visible_modal_text(frame)
        if modal_text:
            self.click_modal_radio_text(frame, "不同意") or self.click_modal_radio_text(frame, "驳回") or self.click_modal_radio_text(frame, "拒绝")
            self.fill_visible_modal_inputs_if_present(frame, reject_reason)
            self.submit_current_modal(frame, ["驳回申诉", "驳回", "不同意", "拒绝", "提交", "确定", "确认"])
        else:
            clicked = self.click_wanzhanggui_detail_action_text(frame, ["驳回申诉", "驳回", "不同意", "拒绝"])
            assert clicked, (
                f"丸掌柜主单申诉处理详情未找到驳回入口；main_order_no={main_order_no}; "
                f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
            self.page.wait_for_timeout(1000)
            self.fill_visible_modal_inputs_if_present(frame, reject_reason)
            self.submit_current_modal(frame, ["驳回申诉", "驳回", "不同意", "拒绝", "提交", "确定", "确认"])

        self.page.wait_for_timeout(3500)
        self.search_order_by_labels(frame, main_order_no, ["订单号", "主单号", "订单编号"])
        final_text = frame.locator("body").inner_text(timeout=5000)
        target_text = self.table_row_text_by_keyword(frame, main_order_no) or self.order_text_after_keyword(final_text, main_order_no)
        assert any(status in target_text for status in ["已完成", "完成", "已处理", "已拒绝", "驳回", "不同意", "拒绝"]), (
            f"主单申诉驳回后未看到完成/驳回状态；main_order_no={main_order_no}; text={final_text[:1500]}"
        )
        assert "处理中" not in target_text and "待处理" not in target_text, (
            f"主单申诉驳回后目标记录仍显示待处理；main_order_no={main_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def find_main_order_no_from_order_list(
        self,
        *,
        source_order_no: str,
        order_title: str = "",
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(frame, source_order_no=source_order_no, order_title=order_title)
        text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in text or (order_title and order_title in text), (
            f"丸掌柜订单管理未找到目标订单，无法提取主单号；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        main_order_no = str(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item =>
                        (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                        (orderTitle && item.text.includes(orderTitle))
                      );
                    const row = rows[0]?.row;
                    if (!row) return '';
                    const candidates = [];
                    const attrs = ['title', 'aria-label', 'data-row-key'];
                    for (const el of Array.from(row.querySelectorAll('*')).concat([row])) {
                      const ownText = (el.innerText || el.textContent || '').trim();
                      if (ownText) candidates.push(ownText);
                      for (const attr of attrs) {
                        const value = el.getAttribute?.(attr);
                        if (value) candidates.push(value);
                      }
                    }
                    const combined = candidates.join('\\n');
                    const match = combined.match(/BO\\d{20}/);
                    return match ? match[0] : '';
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )
        if not main_order_no.startswith("BO"):
            main_order_no = self.copy_main_order_no_from_order_row(
                frame,
                source_order_no=source_order_no,
                order_title=order_title,
            )
        assert main_order_no.startswith("BO"), (
            f"丸掌柜订单管理未提取到完整 BO 主单号；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        return main_order_no

    def copy_main_order_no_from_order_row(self, frame: Frame, *, source_order_no: str, order_title: str = "") -> str:
        clicked = bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item =>
                        (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                        (orderTitle && item.text.includes(orderTitle))
                      );
                    const row = rows[0]?.row;
                    if (!row) return false;
                    const nodes = Array.from(row.querySelectorAll('button, span, i, svg, [class*="copy"], [aria-label*="复制"], [title*="复制"]'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), title: el.getAttribute('title') || el.getAttribute('aria-label') || '', r: el.getBoundingClientRect() }))
                      .filter(item => visible(item.el))
                      .filter(item =>
                        item.text.includes('复制') ||
                        item.title.includes('复制') ||
                        /copy/i.test(String(item.el.className || '')) ||
                        /copy/i.test(String(item.el.closest('button, span, i, svg')?.className || ''))
                      )
                      .sort((a, b) => a.r.x - b.r.x || a.r.y - b.r.y);
                    let target = nodes[0]?.el;
                    if (!target) {
                      const mainLabel = Array.from(row.querySelectorAll('span, div, a'))
                        .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                        .filter(item => visible(item.el))
                        .find(item => item.text.includes('主单号') || item.text.includes('BO'));
                      if (mainLabel) {
                        const point = document.elementFromPoint(mainLabel.r.right + 12, mainLabel.r.y + mainLabel.r.height / 2);
                        target = point?.closest('button, span, i, svg') || point;
                      }
                    }
                    if (!target) return false;
                    target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.getBoundingClientRect().x + 4, clientY: target.getBoundingClientRect().y + 4 }));
                    target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
                    target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
                    target.click();
                    return true;
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )
        if not clicked:
            return ""
        self.page.wait_for_timeout(800)
        for context in [frame, self.page]:
            try:
                clipboard_text = str(context.evaluate("() => navigator.clipboard.readText()"))
            except Exception:
                clipboard_text = ""
            match = re.search(r"BO\d{20}", clipboard_text)
            if match:
                return match.group(0)
        return ""

    def start_aftersale_for_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        reason: str,
        amount: str,
        main_order_no: str = "",
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(frame, main_order_no=main_order_no, source_order_no=source_order_no, order_title=order_title)
        text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in text or order_title in text or (main_order_no and main_order_no in text), (
            f"丸掌柜订单管理未找到发起售后的订单；source_order_no={source_order_no}; "
            f"main_order_no={main_order_no}; order_title={order_title}; text={text[:1500]}"
        )
        assert "待接单" in text, (
            f"S-FLOW-002 前置状态不满足主单待接单；source_order_no={source_order_no}; text={text[:1500]}"
        )

        clicked = self.click_order_row_action(
            frame,
            source_order_no,
            order_title,
            ["发起售后", "申请售后", "售后"],
        )
        if not clicked:
            self.click_order_row_action(frame, source_order_no, order_title, ["更多", "...", "操作"])
            self.page.wait_for_timeout(800)
            clicked = self.click_visible_action_text(frame, ["发起售后", "申请售后", "售后"])
        assert clicked, (
            f"丸掌柜订单管理未找到发起售后入口；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        self.fill_aftersale_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认"])
        self.page.wait_for_timeout(3000)
        self.search_order_by_best_identity(frame, main_order_no=main_order_no, source_order_no=source_order_no, order_title=order_title)
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert any(keyword in final_text for keyword in ["售后中", "售后", "仲裁中"]), (
            f"发起售后后未看到售后状态；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def process_aftersale_for_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        remark: str,
        amount: str,
        main_order_no: str = "",
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待处理售后的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        assert "售后" in before_text, (
            f"S-FLOW-004 前置状态不满足售后中；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )

        clicked = self.click_order_row_action(
            frame,
            source_order_no,
            order_title,
            ["处理售后", "售后处理", "处理", "查看售后", "售后"],
        )
        if not clicked:
            self.click_order_row_action(frame, source_order_no, order_title, ["更多", "...", "操作"])
            self.page.wait_for_timeout(800)
            clicked = self.click_visible_action_text(frame, ["处理售后", "售后处理", "处理", "查看售后", "售后"])
        assert clicked, (
            f"丸掌柜订单管理未找到处理售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        if "处理售后" not in self.visible_modal_text(frame) and "售后处理" not in self.visible_modal_text(frame):
            self.click_current_modal_footer_button(frame, "处理售后") or self.click_current_modal_footer_button(frame, "售后处理")
            self.page.wait_for_timeout(1000)
        self.fill_process_aftersale_modal(frame, remark=remark, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert "已售后" in final_text, (
            f"处理售后后未看到已售后状态；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        target_text = self.order_text_after_keyword(final_text, main_order_no or source_order_no or order_title)
        target_compact = re.sub(r"\s+", "", target_text)
        assert "已售后" in target_text or "售后中" not in target_compact, (
            f"处理售后后目标订单仍显示售后中；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def cancel_aftersale_for_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        reason: str,
        main_order_no: str = "",
        expected_status_after_cancel: str | list[str] | None = None,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待取消售后的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        assert "售后" in self.near_order_text(before_text, main_order_no or source_order_no or order_title), (
            f"S-FLOW-004 前置状态不满足售后中；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )

        clicked = self.click_order_row_action(
            frame,
            source_order_no,
            order_title,
            ["取消售后", "撤销售后"],
        )
        if not clicked:
            self.click_order_row_action(frame, source_order_no, order_title, ["更多", "...", "操作"])
            self.page.wait_for_timeout(800)
            clicked = self.click_visible_action_text(frame, ["取消售后", "撤销售后"])
        if not clicked:
            target_before = self.order_result_row_text(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            if "售后中" not in target_before and "待接单" in target_before:
                return before_text
            if expected_status_after_cancel is not None:
                expected_statuses = (
                    [expected_status_after_cancel]
                    if isinstance(expected_status_after_cancel, str)
                    else list(expected_status_after_cancel)
                )
                if "售后中" not in target_before and any(status in target_before for status in expected_statuses):
                    return before_text
        assert clicked, (
            f"丸掌柜订单管理未找到取消售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        if self.visible_modal_text(frame):
            self.fill_visible_modal_inputs_if_present(frame, reason)
            self.submit_current_modal(frame, ["确认取消", "提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        target_text = (
            self.order_result_row_text(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            or self.order_text_after_keyword(final_text, main_order_no or source_order_no or order_title)
        )
        assert "售后中" not in target_text, (
            f"取消售后后目标订单仍显示售后中；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        expected_statuses = (
            ["待接单", "待推单"]
            if expected_status_after_cancel is None
            else ([expected_status_after_cancel] if isinstance(expected_status_after_cancel, str) else list(expected_status_after_cancel))
        )
        assert any(status in target_text for status in expected_statuses), (
            f"取消售后后未恢复到预期状态；expected={expected_statuses}; main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; target_text={target_text[:1000]}; text={final_text[:1500]}"
        )
        return final_text

    def start_abnormal_sub_order_aftersale(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str,
        amount: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到异常中子单售后前置订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开异常中子单订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "子单列表" in detail_text and "异常中" in detail_text, (
            f"S-FLOW-034 前置不满足：订单详情子单列表未看到异常中子单；"
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )

        clicked = self.click_sub_order_list_action_by_status(
            frame,
            ["异常中"],
            ["发起售后", "申请售后", "售后"],
        )
        assert clicked, (
            f"S-FLOW-034 未找到异常中子单行内售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        self.fill_aftersale_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_sub_order_list_status="售后中",
        )

    def start_sub_order_aftersale_by_status(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        sub_order_statuses: list[str],
        reason: str,
        amount: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到子单售后前置订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开子单售后前置订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "子单列表" in detail_text and any(status in detail_text for status in sub_order_statuses), (
            f"子单售后前置不满足：订单详情子单列表未看到目标子单状态；expected={sub_order_statuses}; "
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )

        clicked = self.click_sub_order_row_action_by_status(
            frame,
            sub_order_statuses,
            ["发起售后", "申请售后", "售后"],
        )
        if not clicked:
            clicked = self.click_sub_order_list_action_by_status(
                frame,
                sub_order_statuses,
                ["发起售后", "申请售后", "售后"],
            )
        assert clicked, (
            f"未找到目标子单行内售后入口；expected={sub_order_statuses}; "
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        self.fill_aftersale_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_sub_order_list_status="售后中",
        )

    def start_abnormal_main_order_aftersale(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str,
        amount: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到异常中主单售后前置订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开异常中主单订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)

        clicked = self.click_main_order_aftersale_action(frame, ["发起售后", "申请售后", "售后"])
        assert clicked, (
            f"S-FLOW-065 未找到订单详情主单售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        self.fill_aftersale_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_main_status=["售后中", "异常中", "代练中", "服务中"],
            expected_sub_status=["售后中", "代练中", "异常中"],
            expected_aftersale_status=["售后中", "待处理", "处理中"],
        )

    def start_pending_acceptance_main_order_aftersale(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str,
        amount: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待验收主单售后前置订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开待验收主单订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "待验收" in detail_text, (
            f"S-FLOW-046 前置不满足主单/子单待验收；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )

        clicked = self.click_main_order_aftersale_action(frame, ["发起售后", "申请售后", "售后"])
        assert clicked, (
            f"S-FLOW-046 未找到订单详情主单售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        self.fill_aftersale_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_main_status=["售后中", "待验收"],
            expected_sub_status=["售后中", "待验收"],
            expected_aftersale_status=["售后中", "待处理", "处理中"],
        )

    def cancel_pending_acceptance_main_order_aftersale(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str,
    ) -> str:
        return self.cancel_aftersale_for_order(
            source_order_no,
            order_title,
            main_order_no=main_order_no,
            reason=reason,
            expected_status_after_cancel=["待验收", "等待验收"],
        )

    def click_main_order_aftersale_action(self, frame: Frame, action_texts: list[str]) -> bool:
        clicked = bool(
            frame.evaluate(
                """actionTexts => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, body'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots[0] || document.body;
                    const normalize = value => String(value || '').replace(/\\s+/g, '');
                    const actions = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
                      .map(el => {
                        const clickable = el.closest('button, a, [role="button"]') || el;
                        const row = el.closest('tbody tr, .ant-table-row');
                        const r = clickable.getBoundingClientRect();
                        return {
                          el,
                          clickable,
                          row,
                          text: el.innerText || el.textContent || '',
                          compact: normalize(el.innerText || el.textContent || ''),
                          r,
                          disabled: Boolean(clickable.disabled)
                        };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .filter(item => !item.row);
                    for (const actionText of actionTexts) {
                      const targetText = normalize(actionText);
                      const candidates = actions
                        .filter(item => item.compact === targetText || (item.compact.includes(targetText) && item.compact.length <= targetText.length + 6))
                        .sort((a, b) => {
                          const aExact = a.compact === targetText ? 0 : 1;
                          const bExact = b.compact === targetText ? 0 : 1;
                          return aExact - bExact || b.r.y - a.r.y || b.r.x - a.r.x;
                        });
                      const target = candidates[0];
                      if (!target) continue;
                      target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                      const r = target.clickable.getBoundingClientRect();
                      target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      target.clickable.click();
                      return true;
                    }
                    return false;
                }""",
                action_texts,
            )
        )
        self.page.wait_for_timeout(800)
        return clicked

    def cancel_abnormal_sub_order_aftersale(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        reason: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待取消异常中子单售后的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开待取消异常中子单售后的订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "子单列表" in detail_text and "售后中" in detail_text, (
            f"取消异常中子单售后前置不满足：订单详情子单列表未看到售后中子单；"
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )

        clicked = self.click_sub_order_list_action_by_status(
            frame,
            ["售后中"],
            ["取消售后", "撤销售后"],
        )
        if not clicked:
            clicked = self.click_wanzhanggui_detail_action_text(frame, ["取消售后", "撤销售后"])
        assert clicked, (
            f"未找到售后中子单取消售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        if self.visible_modal_text(frame):
            self.fill_visible_modal_inputs_if_present(frame, reason)
            self.submit_current_modal(frame, ["确认取消", "提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_main_status=["异常中", "代练中", "服务中"],
            expected_sub_status=["代练中", "异常中"],
            expected_sub_order_list_status=["异常中", "发单方取消撤销"],
            expected_latest_sub_order_list_status=["异常中", "发单方取消撤销"],
            expected_aftersale_absent=True,
        )

    def cancel_sub_order_aftersale_by_status(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        expected_status_after_cancel: str | list[str],
        reason: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待取消子单售后的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开待取消子单售后的订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "子单列表" in detail_text and "售后中" in detail_text, (
            f"子单售后取消前置不满足：订单详情子单列表未看到售后中子单；"
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )

        clicked = self.click_sub_order_row_action_by_status(frame, ["售后中"], ["取消售后", "撤销售后"])
        if not clicked:
            clicked = self.click_sub_order_list_action_by_status(frame, ["售后中"], ["取消售后", "撤销售后"])
        if not clicked:
            clicked = self.click_wanzhanggui_detail_action_text(frame, ["取消售后", "撤销售后"])
        assert clicked, (
            f"未找到售后中子单取消售后入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        if self.visible_modal_text(frame):
            self.fill_visible_modal_inputs_if_present(frame, reason)
            self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        expected_statuses = (
            [expected_status_after_cancel]
            if isinstance(expected_status_after_cancel, str)
            else list(expected_status_after_cancel)
        )
        return self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_main_status=["代练中", "服务中", "待接单"],
            expected_sub_status=["代练中", "服务中", "已接单", "待接单-已推单"],
            expected_sub_order_list_status=expected_statuses,
            expected_latest_sub_order_list_status=expected_statuses,
            expected_aftersale_absent=True,
        )

    def order_result_row_text(
        self,
        frame: Frame,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
    ) -> str:
        return str(
            frame.evaluate(
                """({ mainOrderNo, sourceOrderNo, orderTitle }) => {
                    const visibleRows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text && !item.text.includes('订单编号\\t订单信息'));
                    const matched = visibleRows.find(item =>
                      (mainOrderNo && item.text.includes(mainOrderNo)) ||
                      (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                      (orderTitle && item.text.includes(orderTitle))
                    );
                    if (matched) return matched.text;
                    const bodyText = (document.body.innerText || document.body.textContent || '').replace(/\\s+/g, '');
                    const hasSingleResult = bodyText.includes('共1条记录') || bodyText.includes('共1條記錄');
                    if (!hasSingleResult) return '';
                    const dataRow = visibleRows.find(item =>
                      item.text.includes('主单号') ||
                      item.text.includes('来源单号') ||
                      item.text.includes('创建工单') ||
                      item.text.includes('推单') ||
                      /BO\\d{6,}\\.{3}/.test(item.text)
                    );
                    return dataRow?.text || '';
                }""",
                {
                    "mainOrderNo": main_order_no,
                    "sourceOrderNo": source_order_no,
                    "orderTitle": order_title,
                },
            )
        )

    def start_appeal_for_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        reason: str,
        amount: str,
        main_order_no: str = "",
        expected_main_status: str | list[str] = "已售后",
        case_label: str = "S-FLOW-051",
    ) -> str:
        expected_statuses = (
            [expected_main_status]
            if isinstance(expected_main_status, str)
            else list(expected_main_status)
        )
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待发起申诉的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        target_before = self.order_text_after_keyword(before_text, main_order_no or source_order_no or order_title)
        assert any(status in target_before for status in expected_statuses), (
            f"{case_label} 前置状态不满足 {expected_statuses}；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; row_text={target_before[:1000]}; text={before_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            source_order_no=source_order_no,
            order_title=order_title,
            main_order_no=main_order_no,
        )
        assert opened, (
            f"丸掌柜订单管理未打开主单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        clicked = self.click_wanzhanggui_detail_action_text(frame, ["主单发起申诉", "发起申诉", "申诉"])
        assert clicked, (
            f"丸掌柜主单详情未找到主单发起申诉入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; detail_text={detail_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        self.fill_appeal_modal(frame, reason=reason, amount=amount)
        self.submit_current_modal(frame, ["提交", "确定", "确认", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        target_text = self.order_text_after_keyword(final_text, main_order_no or source_order_no or order_title)
        assert any(status in target_text for status in expected_statuses), (
            f"发起申诉后主单未保持 {expected_statuses} 状态；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; row_text={target_text[:1000]}; text={final_text[:1500]}"
        )
        assert "售后中" not in target_text, (
            f"发起申诉后目标订单回退到售后中；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        assert "申诉" in target_text or "申诉" in final_text, (
            f"发起申诉后未看到申诉相关状态或记录；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def start_penalty_for_aftersale_ended_sub_order(
        self,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
        reason: str,
        amount: str,
    ) -> str:
        return self.start_penalty_for_sub_order_by_status(
            source_order_no=source_order_no,
            order_title=order_title,
            main_order_no=main_order_no,
            reason=reason,
            amount=amount,
            sub_order_status="售后结束",
            case_label="S-FLOW-062",
        )

    def start_penalty_for_revoke_ended_sub_order(
        self,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
        reason: str,
        amount: str,
    ) -> str:
        return self.start_penalty_for_sub_order_by_status(
            source_order_no=source_order_no,
            order_title=order_title,
            main_order_no=main_order_no,
            reason=reason,
            amount=amount,
            sub_order_status="撤销结束",
            case_label="S-FLOW-069",
        )

    def start_penalty_for_accepted_sub_order(
        self,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
        reason: str,
        amount: str,
    ) -> str:
        return self.start_penalty_for_sub_order_by_status(
            source_order_no=source_order_no,
            order_title=order_title,
            main_order_no=main_order_no,
            reason=reason,
            amount=amount,
            sub_order_status=["验收成功", "已完成"],
            case_label="S-FLOW-054",
        )

    def start_penalty_for_sub_order_by_status(
        self,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
        reason: str,
        amount: str,
        sub_order_status: str | list[str],
        case_label: str,
    ) -> str:
        sub_order_statuses = [sub_order_status] if isinstance(sub_order_status, str) else list(sub_order_status)
        expected_status_text = "/".join(sub_order_statuses)
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待发起处罚的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未打开待发起处罚订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert any(status in detail_text for status in sub_order_statuses), (
            f"{case_label} 前置状态不满足{expected_status_text}子单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        clicked = self.click_sub_order_row_action_by_status(frame, sub_order_statuses, ["处罚", "处 罚", "发起处罚"])
        assert clicked, (
            f"{expected_status_text}子单行未找到【处罚】入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        if "处罚单" not in modal_text:
            current_text = frame.locator("body").inner_text(timeout=5000)
            if "申诉中" in current_text or "处罚中" in current_text or "发起申诉" in current_text:
                self.record_missing_penalty_submit_notice(
                    frame,
                    main_order_no=main_order_no,
                    source_order_no=source_order_no,
                    page_text=current_text,
                )
                return current_text
        assert modal_text, (
            f"点击{expected_status_text}子单【处罚】后未打开处罚弹框；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        assert "处罚单" in modal_text, (
            f"点击{expected_status_text}子单【处罚】后未打开处罚单弹框；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; modal={modal_text[:1500]}"
        )
        self.select_modal_dropdown_first_option_by_labels(frame, ["处罚类型", "类型"], field_name="处罚类型", required=False)
        self.select_modal_dropdown_first_option_by_labels(frame, ["处罚对象", "责任方", "对象"], field_name="处罚对象", required=False)
        self.fill_penalty_modal_fields(frame, amount=amount, reason=reason)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = (
            self.click_current_modal_footer_button(frame, "申请处罚")
            or self.click_current_modal_footer_button(frame, "确认处罚")
            or self.click_current_modal_footer_button(frame, "发起处罚")
            or self.click_current_modal_footer_button(frame, "提交")
            or self.click_current_modal_footer_button(frame, "确定")
            or self.click_current_modal_footer_button(frame, "确认")
        )
        assert submitted, (
            f"处罚弹框未点击到【申请处罚/确认处罚/提交】按钮；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(3500)
        current_modal_text = self.visible_modal_text(frame)
        assert "处罚单" not in current_modal_text, (
            f"点击申请处罚后处罚单弹框未关闭，处罚流程可能未提交成功；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; modal={current_modal_text[:1500]}"
        )
        page_text_after_submit = frame.locator("body").inner_text(timeout=5000)
        self.record_missing_penalty_submit_notice(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            page_text=page_text_after_submit,
        )
        return page_text_after_submit

    def fill_penalty_modal_fields(self, frame: Frame, *, amount: str, reason: str) -> None:
        amount_value = str(amount).strip()
        assert amount_value.isdigit(), f"处罚金额必须填写整数数字；当前值：{amount}"
        result = frame.evaluate(
            """({ amount, reason }) => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return { amountCount: 0, reasonCount: 0, modalText: '' };
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  el.focus();
                  setter.call(el, '');
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' }));
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                const fields = Array.from(root.querySelectorAll('.ant-form-item, [class*="form-item"], [class*="formItem"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim() }))
                  .filter(item => visible(item.el));
                let amountCount = 0;
                let reasonCount = 0;
                const amountField = fields.find(item => /处罚金额|金额|赔偿金额/.test(item.text));
                const amountInput = amountField?.el.querySelector('input:not([type="file"]):not([type="hidden"]), textarea') ||
                  Array.from(root.querySelectorAll('input:not([type="file"]):not([type="hidden"])'))
                    .filter(visible)
                    .find(el => /金额|处罚/.test(el.getAttribute('placeholder') || '') || el.getAttribute('type') === 'number');
                if (amountInput) {
                  setValue(amountInput, amount);
                  amountCount += 1;
                }
                const reasonField = fields.find(item => /处罚原因|原因|理由|说明|备注/.test(item.text));
                const reasonInput = reasonField?.el.querySelector('textarea, input:not([type="file"]):not([type="hidden"])') ||
                  Array.from(root.querySelectorAll('textarea, input:not([type="file"]):not([type="hidden"])'))
                    .filter(visible)
                    .find(el => /原因|理由|说明|备注/.test(el.getAttribute('placeholder') || '') || el.tagName === 'TEXTAREA');
                if (reasonInput) {
                  setValue(reasonInput, reason);
                  reasonCount += 1;
                }
                return { amountCount, reasonCount, modalText: root.innerText || root.textContent || '' };
            }""",
            {"amount": amount_value, "reason": reason},
        )
        self.page.wait_for_timeout(500)
        assert result and result.get("amountCount", 0) > 0, (
            f"处罚弹框未找到或未写入处罚金额；modal={str((result or {}).get('modalText', ''))[:1500]}"
        )

    def record_missing_penalty_submit_notice(
        self,
        frame: Frame,
        *,
        main_order_no: str,
        source_order_no: str,
        page_text: str,
    ) -> None:
        if not self.bug_reporter:
            return
        notice_keywords = [
            "成功",
            "提交成功",
            "申请成功",
            "处罚成功",
            "已提交",
            "操作成功",
            "失败",
            "错误",
            "异常",
            "不能为空",
            "请输入",
        ]
        if any(keyword in page_text for keyword in notice_keywords):
            return
        self.bug_reporter.write_bug_report(
            account="丸掌柜",
            location=(
                "S-FLOW-062 点击【申请处罚】后页面无成功/失败提示；"
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
                f"page_text={page_text[:1000]}"
            ),
            frontend_errors=["点击【申请处罚】后未出现任何成功/失败提示，需要截图作为缺陷依据"],
            network_issues=[],
        )

    def dispatch_pending_main_order_to_wanyouji(
        self,
        *,
        source_order_no: str,
        order_title: str,
        strategy_name: str,
        strategy_type: str = "单平台指定",
        main_order_no: str = "",
        dispatch_price: str = "70",
        allow_in_service: bool = False,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert (
            source_order_no in before_text
            or order_title in before_text
            or (main_order_no and main_order_no in before_text)
            or self.filtered_order_list_has_single_result(before_text)
        ), (
            f"丸掌柜订单管理未找到待推单主单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={before_text[:1500]}"
        )
        target_before = self.order_text_after_keyword(before_text, main_order_no or source_order_no or order_title)
        allowed_statuses = ["待接单"]
        if allow_in_service:
            allowed_statuses.extend(["代练中", "服务中", "异常中"])
        assert any(status in target_before or status in before_text for status in allowed_statuses), (
            f"继续发单前置主单状态不满足 {allowed_statuses}；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )

        clicked = self.click_order_row_action(frame, source_order_no, order_title, ["推单", "继续发单", "发子单", "继续发子单"])
        if not clicked and self.filtered_order_list_has_single_result(before_text):
            clicked = self.click_visible_action_text(frame, ["推单", "继续发单", "发子单", "继续发子单"])
        if not clicked:
            opened = self.open_main_order_detail_from_list(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            assert opened, (
                f"丸掌柜订单管理未能打开待推单主单详情；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; text={before_text[:1500]}"
            )
            detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
            clicked = self.click_wanzhanggui_detail_action_text(frame, ["推单", "继续发单", "发子单", "继续发子单"])
            assert clicked, (
                f"丸掌柜订单详情未找到推单入口；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )

        self.page.wait_for_timeout(1800)
        if not self.dispatch_sub_order_form_visible(frame):
            detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
            clicked_detail_dispatch = self.click_detail_sub_order_dispatch_button(frame)
            assert clicked_detail_dispatch, (
                f"点击列表推单后未进入推单表单，且详情内未找到推单入口；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
            for _ in range(8):
                if self.dispatch_sub_order_form_visible(frame):
                    break
                self.page.wait_for_timeout(500)
        assert self.dispatch_sub_order_form_visible(frame), (
            f"点击推单后未进入推单表单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        modal_text = self.visible_modal_text(frame)
        dispatch_order = ManualOrderData(
            source_order_no=source_order_no,
            order_title=order_title,
            strategy_name=strategy_name,
            strategy_type=strategy_type,
            dispatch_price=dispatch_price,
        )
        self.fill_sub_order_row(frame, dispatch_order)
        self.assert_sub_order_price_mode_ready(frame, dispatch_order)
        submitted = self.click_embedded_dispatch_submit(frame) or self.click_optional_frame_buttons(
            frame,
            ["推 单", "推单", "提交订单", "确认推单", "确认发单", "提交", "确定"],
        )
        assert submitted, f"推单表单未找到提交按钮；text={(modal_text or frame.locator('body').inner_text(timeout=5000))[:1500]}"
        self.page.wait_for_timeout(4000)
        self.record_order_submit_errors(dispatch_order)

        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        target_text = self.order_text_after_keyword(final_text, main_order_no or source_order_no or order_title)
        assert any(status in target_text or status in final_text for status in allowed_statuses), (
            f"推单后主单状态不符合 {allowed_statuses}；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        assert "已推单" in target_text or "已推单" in final_text or strategy_name in final_text, (
            f"推单后未看到待接单-已推单状态或目标策略；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; strategy_name={strategy_name}; text={final_text[:1500]}"
        )
        return final_text

    def dispatch_sub_order_form_visible(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    return roots.some(root => {
                      const text = root.innerText || root.textContent || '';
                      const placeholders = Array.from(root.querySelectorAll('input, textarea'))
                      .map(el => el.getAttribute('placeholder') || '')
                      .join('\\n');
                      return /请添加子单/.test(text) ||
                        /请输入发单价|请输入代练时限|请输入安全保证金|请输入效率保证金/.test(placeholders);
                    });
                }"""
            )
        )

    def click_detail_sub_order_dispatch_button(self, frame: Frame) -> bool:
        clicked = bool(
            frame.evaluate(
                """() => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const modals = Array.from(document.querySelectorAll('.ant-modal')).filter(visible);
                    const modal = modals.pop();
                    if (!modal) return false;
                    const text = modal.innerText || modal.textContent || '';
                    if (!/订单详情|子单列表/.test(text)) return false;
                    const buttons = Array.from(modal.querySelectorAll('button'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .sort((a, b) => a.r.y - b.r.y || b.r.x - a.r.x);
                    const subOrderIndex = text.indexOf('子单列表');
                    const target = buttons.find(item =>
                      (item.text === '推 单' || item.text === '推单' || item.text === '继续发单' || item.text === '继续发子单') &&
                      item.r.y > 300
                    ) || buttons.find(item =>
                      item.text === '推 单' || item.text === '推单' || item.text === '继续发单' || item.text === '继续发子单'
                    );
                    if (!target) return false;
                    target.el.scrollIntoView({ block: 'center', inline: 'center' });
                    target.el.click();
                    return true;
                }"""
            )
        )
        self.page.wait_for_timeout(800)
        return clicked

    def click_embedded_dispatch_submit(self, frame: Frame) -> bool:
        clicked = bool(
            frame.evaluate(
                """() => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const roots = Array.from(document.querySelectorAll('.ant-modal, body')).filter(visible);
                    const root = roots.find(el => /请添加子单|请输入发单价|固定发单价/.test(el.innerText || el.textContent || '')) || roots[0];
                    if (!root) return false;
                    const buttons = Array.from(root.querySelectorAll('button'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .sort((a, b) => b.r.y - a.r.y || b.r.x - a.r.x);
                    const target = buttons.find(item => item.text === '推 单' || item.text === '推单') ||
                      buttons.find(item => /确认推单|确认发单|提交订单|提交|确定/.test(item.text));
                    if (!target) return false;
                    target.el.scrollIntoView({ block: 'center', inline: 'center' });
                    target.el.click();
                    return true;
                }"""
            )
        )
        self.page.wait_for_timeout(800)
        return clicked

    def open_main_order_detail_from_list(
        self,
        frame: Frame,
        *,
        source_order_no: str,
        order_title: str,
        main_order_no: str = "",
    ) -> bool:
        opened = bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle, mainOrderNo }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item =>
                        (sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                        (orderTitle && item.text.includes(orderTitle)) ||
                        (mainOrderNo && item.text.includes(mainOrderNo))
                      );
                    let row = rows[0]?.row;
                    if (!row && mainOrderNo) {
                      const bodyText = (document.body.innerText || document.body.textContent || '').replace(/\\s+/g, '');
                      const hasSingleResult = bodyText.includes('共1条记录') || bodyText.includes('共1條記錄');
                      if (hasSingleResult) {
                        row = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                          .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                          .filter(item => item.r.width > 0 && item.r.height > 0)
                          .find(item => item.text.includes('主单号') || item.text.includes('来源单号') || /BO\\d{6,}\\.{3}/.test(item.text))
                          ?.row;
                      }
                    }
                    if (!row) return false;

                    const firstCell = row.querySelector('td, [class*="cell"], [class*="column"]') || row;
                    const candidates = Array.from(firstCell.querySelectorAll('a, button, span, div'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => visible(item.el))
                      .filter(item =>
                        item.text.includes('主单号') ||
                        item.text.includes('订单编号') ||
                        /BO\\d{6,}/.test(item.text) ||
                        (mainOrderNo && item.text.includes(mainOrderNo))
                      )
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                    const target = candidates[0];
                    if (target) {
                      target.el.click();
                      return true;
                    }

                    const r = firstCell.getBoundingClientRect();
                    const x = r.left + Math.min(Math.max(30, r.width * 0.35), r.width - 8);
                    const y = r.top + Math.min(Math.max(18, r.height * 0.25), r.height - 8);
                    const pointTarget = document.elementFromPoint(x, y);
                    if (!pointTarget) return false;
                    pointTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
                    pointTarget.click();
                    return true;
                }""",
                {
                    "sourceOrderNo": source_order_no,
                    "orderTitle": order_title,
                    "mainOrderNo": main_order_no,
                },
            )
        )
        self.page.wait_for_timeout(1500)
        return opened

    def assert_order_detail_statuses(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        expected_main_status: str | list[str] | None = None,
        expected_sub_status: str | list[str] | None = None,
        expected_sub_status_parts: list[str] | None = None,
        expected_aftersale_status: str | list[str] | None = None,
        expected_aftersale_absent: bool = False,
        expected_appeal_status: str | list[str] | None = None,
        expected_sub_order_list_status: str | list[str] | None = None,
        expected_latest_sub_order_list_status: str | list[str] | None = None,
    ) -> str:
        frame = self.open_order_management()
        list_text = ""
        for attempt in range(3):
            self.search_order_by_best_identity(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            list_text = frame.locator("body").inner_text(timeout=5000)
            if (
                (main_order_no and main_order_no in list_text)
                or (source_order_no and source_order_no in list_text)
                or (order_title and order_title in list_text)
                or self.filtered_order_list_has_single_result(list_text)
            ):
                break
            if "HTTP错误" in list_text or "502" in list_text or "全部 (0)" in list_text:
                self.page.wait_for_timeout(2500)
                frame = self.open_order_management()
                continue
            break
        assert (
            (main_order_no and main_order_no in list_text)
            or (source_order_no and source_order_no in list_text)
            or (order_title and order_title in list_text)
            or self.filtered_order_list_has_single_result(list_text)
        ), (
            f"丸掌柜订单列表未找到待校验详情状态的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={list_text[:1500]}"
        )

        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单列表未能点击第一列订单编号进入详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={list_text[:1500]}"
        )

        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        if expected_main_status:
            main_statuses = [expected_main_status] if isinstance(expected_main_status, str) else list(expected_main_status)
            assert any(status in detail_text for status in main_statuses), (
                f"订单详情主单状态不符合预期；expected={main_statuses}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
        if expected_sub_status:
            sub_statuses = [expected_sub_status] if isinstance(expected_sub_status, str) else list(expected_sub_status)
            assert any(status in detail_text for status in sub_statuses), (
                f"订单详情子单状态不符合预期；expected={sub_statuses}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
        for expected_part in expected_sub_status_parts or []:
            assert expected_part in detail_text, (
                f"订单详情子单状态不符合预期，缺少「{expected_part}」；"
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
        if expected_aftersale_status:
            aftersale_statuses = (
                [expected_aftersale_status]
                if isinstance(expected_aftersale_status, str)
                else list(expected_aftersale_status)
            )
            aftersale_section_text = self.detail_section_text(detail_text, "售后信息")
            assert aftersale_section_text, (
                f"订单详情缺少售后信息区域；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
            assert any(status in aftersale_section_text for status in aftersale_statuses), (
                f"订单详情售后信息状态不符合预期；expected={aftersale_statuses}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
                f"aftersale_section={aftersale_section_text[:1000]}; detail={detail_text[:1500]}"
            )
        if expected_aftersale_absent:
            aftersale_section_text = self.detail_section_text(detail_text, "售后信息")
            assert not aftersale_section_text, (
                f"订单详情售后信息区域应不存在；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; aftersale_section={aftersale_section_text[:1000]}; "
                f"detail={detail_text[:1500]}"
            )
        if expected_appeal_status:
            appeal_statuses = (
                [expected_appeal_status]
                if isinstance(expected_appeal_status, str)
                else list(expected_appeal_status)
            )
            appeal_section_text = self.detail_section_text(detail_text, "申诉信息")
            assert appeal_section_text, (
                f"订单详情缺少申诉信息区域；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
            assert any(status in appeal_section_text for status in appeal_statuses), (
                f"订单详情申诉信息状态不符合预期；expected={appeal_statuses}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
                f"appeal_section={appeal_section_text[:1000]}; detail={detail_text[:1500]}"
            )
        if expected_sub_order_list_status:
            sub_order_list_statuses = (
                [expected_sub_order_list_status]
                if isinstance(expected_sub_order_list_status, str)
                else list(expected_sub_order_list_status)
            )
            sub_order_list_text = self.detail_section_text(detail_text, "子单列表")
            assert sub_order_list_text, (
                f"订单详情缺少子单列表区域；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
            assert any(status in sub_order_list_text for status in sub_order_list_statuses), (
                f"订单详情子单列表订单状态不符合预期；expected={sub_order_list_statuses}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
                f"sub_order_list={sub_order_list_text[:1000]}; detail={detail_text[:1500]}"
            )
        if expected_latest_sub_order_list_status:
            latest_sub_order_statuses = (
                [expected_latest_sub_order_list_status]
                if isinstance(expected_latest_sub_order_list_status, str)
                else list(expected_latest_sub_order_list_status)
            )
            sub_order_list_text = self.detail_section_text(detail_text, "子单列表")
            assert sub_order_list_text, (
                f"订单详情缺少子单列表区域；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
            )
            rows = self.extract_sub_order_rows(sub_order_list_text)
            assert rows, (
                f"订单详情子单列表未解析到有效子单行；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; sub_order_list={sub_order_list_text[:1000]}"
            )
            latest_row = rows[0]
            assert any(status in latest_row for status in latest_sub_order_statuses), (
                f"订单详情最新子单状态不符合预期；expected={latest_sub_order_statuses}; "
                f"latest_row={latest_row}; rows={rows[:5]}; "
                f"main_order_no={main_order_no}; source_order_no={source_order_no}"
            )
        return detail_text

    def assert_latest_sub_order_published_with_rejected_history(self, detail_text: str) -> None:
        sub_order_list_text = self.detail_section_text(detail_text, "子单列表")
        assert sub_order_list_text, f"订单详情缺少子单列表区域；detail={detail_text[:1500]}"
        assert "已发布" in sub_order_list_text, (
            f"子单列表未看到最新已发布子单；sub_order_list={sub_order_list_text[:1500]}"
        )
        assert "拒绝结束" in sub_order_list_text, (
            f"子单列表未保留拒绝结束历史子单；sub_order_list={sub_order_list_text[:1500]}"
        )

        rows = self.extract_sub_order_rows(sub_order_list_text)
        if not rows:
            return
        status_rows = [row for row in rows if "已发布" in row or "拒绝结束" in row]
        assert status_rows, f"子单列表未解析到有效状态行；sub_order_list={sub_order_list_text[:1500]}"
        assert "已发布" in status_rows[0], (
            f"子单列表最新状态行不是已发布；latest_row={status_rows[0]}; rows={status_rows[:5]}"
        )
        assert any("拒绝结束" in row for row in status_rows[1:]) or any(
            "拒绝结束" in row for row in status_rows
        ), f"子单列表历史行未保留拒绝结束；rows={status_rows[:8]}"

    def assert_latest_sub_order_published_with_cancelled_history(self, detail_text: str) -> None:
        sub_order_list_text = self.detail_section_text(detail_text, "子单列表")
        assert sub_order_list_text, f"订单详情缺少子单列表区域；detail={detail_text[:1500]}"
        assert "已发布" in sub_order_list_text, (
            f"子单列表未看到最新已发布子单；sub_order_list={sub_order_list_text[:1500]}"
        )
        assert "取消订单" in sub_order_list_text or "已撤销" in sub_order_list_text, (
            f"子单列表未保留取消/撤销历史子单；sub_order_list={sub_order_list_text[:1500]}"
        )

        rows = self.extract_sub_order_rows(sub_order_list_text)
        if not rows:
            return
        status_rows = [
            row
            for row in rows
            if "已发布" in row or "取消订单" in row or "已撤销" in row or "拒绝结束" in row
        ]
        assert status_rows, f"子单列表未解析到有效状态行；sub_order_list={sub_order_list_text[:1500]}"
        assert "已发布" in status_rows[0], (
            f"子单列表最新状态行不是已发布；latest_row={status_rows[0]}; rows={status_rows[:5]}"
        )
        assert any("取消订单" in row or "已撤销" in row for row in status_rows[1:]) or any(
            "取消订单" in row or "已撤销" in row for row in status_rows
        ), f"子单列表历史行未保留取消/撤销状态；rows={status_rows[:8]}"

    def assert_latest_sub_order_published_with_revoke_end_history(self, detail_text: str) -> None:
        sub_order_list_text = self.detail_section_text(detail_text, "子单列表")
        assert sub_order_list_text, f"订单详情缺少子单列表区域；detail={detail_text[:1500]}"
        assert "已发布" in sub_order_list_text, (
            f"子单列表未看到最新已发布子单；sub_order_list={sub_order_list_text[:1500]}"
        )
        assert "撤销结束" in sub_order_list_text, (
            f"子单列表未保留撤销结束历史子单；sub_order_list={sub_order_list_text[:1500]}"
        )

        rows = self.extract_sub_order_rows(sub_order_list_text)
        if not rows:
            return
        status_rows = [row for row in rows if "已发布" in row or "撤销结束" in row]
        assert status_rows, f"子单列表未解析到有效状态行；sub_order_list={sub_order_list_text[:1500]}"
        sorted_rows = sorted(status_rows, key=lambda row: row.split("\t", 1)[0].strip(), reverse=True)
        assert "已发布" in sorted_rows[0], (
            f"子单列表最新状态行不是已发布；latest_row={sorted_rows[0]}; rows={sorted_rows[:5]}"
        )
        assert any("撤销结束" in row for row in sorted_rows[1:]) or any(
            "撤销结束" in row for row in sorted_rows
        ), f"子单列表历史行未保留撤销结束；rows={sorted_rows[:8]}"

    @staticmethod
    def extract_sub_order_rows(sub_order_list_text: str) -> list[str]:
        rows: list[str] = []
        for line in (sub_order_list_text or "").splitlines():
            line = line.strip()
            if not line or "\t" not in line:
                continue
            if line.startswith("供应链单号") or line.startswith("订单标题"):
                continue
            if (
                "ORB" not in line
                and "已发布" not in line
                and "拒绝结束" not in line
                and "取消订单" not in line
                and "已撤销" not in line
                and "撤销中" not in line
                and "服务中" not in line
                and "售后结束" not in line
                and "撤销结束" not in line
            ):
                continue
            rows.append(line)
        return rows

    @staticmethod
    def filtered_order_list_has_single_result(text: str) -> bool:
        compact = re.sub(r"\s+", "", text or "")
        return "共1条记录" in compact or "共1條記錄" in compact

    @staticmethod
    def detail_section_text(text: str, heading: str) -> str:
        lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
        start = lines.index(heading) if heading in lines else -1
        if start < 0:
            for index, line in enumerate(lines):
                if line.startswith(heading):
                    start = index
                    break
        if start < 0:
            compact = re.sub(r"\s+", "", text or "")
            if heading not in compact:
                return ""
            return compact[compact.find(heading) :]

        stop_headings = {
            "订单信息",
            "主单信息",
            "子单信息",
            "子单列表",
            "售后信息",
            "申诉信息",
            "处罚信息",
            "赔付信息",
            "留言信息",
            "操作日志",
        }
        section = [lines[start]]
        for line in lines[start + 1 :]:
            if line in stop_headings and line != heading:
                break
            section.append(line)
        return "\n".join(section)

    def click_wanzhanggui_detail_action_text(self, frame: Frame, action_texts: list[str]) -> bool:
        clicked = bool(
            frame.evaluate(
                """actionTexts => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, .ant-popover, body'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots[0] || document.body;
                    const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
                      .map(el => {
                        const clickable = el.closest('button, a, [role="button"]') || el;
                        return {
                          el,
                          clickable,
                          text: (el.innerText || el.textContent || '').trim(),
                          r: el.getBoundingClientRect(),
                          disabled: Boolean(clickable.disabled)
                        };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .sort((a, b) => b.r.y - a.r.y || a.text.length - b.text.length);
                    for (const actionText of actionTexts) {
                      const target = nodes.find(item => item.text === actionText) ||
                        nodes.find(item => item.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, '')) ||
                        nodes.find(item => item.text.includes(actionText));
                      if (target) {
                        target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                        target.clickable.click();
                        return true;
                      }
                    }
                    return false;
                }""",
                action_texts,
            )
        )
        self.page.wait_for_timeout(800)
        return clicked

    def table_row_text_by_keyword(self, frame: Frame, keyword: str) -> str:
        if not keyword:
            return ""
        return str(
            frame.evaluate(
                """keyword => {
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text.includes(keyword));
                    return rows[0]?.text || '';
                }""",
                keyword,
            )
        )

    def table_row_text_by_keywords(self, frame: Frame, keywords: list[str]) -> str:
        required = [keyword for keyword in keywords if keyword]
        if not required:
            return ""
        return str(
            frame.evaluate(
                """keywords => {
                    const matchesKeyword = (text, keyword) => {
                      if (!keyword) return true;
                      if (text.includes(keyword)) return true;
                      return keyword.length > 13 && text.includes(keyword.slice(0, 13));
                    };
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                      .map(row => ({ text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => keywords.every(keyword => matchesKeyword(item.text, keyword)));
                    return rows[0]?.text || '';
                }""",
                required,
            )
        )

    def table_row_amounts_by_keywords(self, frame: Frame, keywords: list[str]) -> list[str]:
        required = [keyword for keyword in keywords if keyword]
        if not required:
            return []
        values = frame.evaluate(
            """keywords => {
                const matchesKeyword = (text, keyword) => {
                  if (!keyword) return true;
                  if (text.includes(keyword)) return true;
                  return keyword.length > 13 && text.includes(keyword.slice(0, 13));
                };
                const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.every(keyword => matchesKeyword(item.text, keyword)));
                const row = rows[0]?.row;
                if (!row) return [];
                const cells = Array.from(row.querySelectorAll('td, .ant-table-cell'))
                  .map(cell => (cell.innerText || cell.textContent || '').trim())
                  .filter(Boolean);
                const amounts = [];
                for (const cell of cells) {
                  const matches = cell.match(/[¥￥\\u00a5]\\s*\\d+(?:\\.\\d{1,2})?/g) || [];
                  amounts.push(...matches);
                }
                return amounts;
            }""",
            required,
        )
        if not isinstance(values, list):
            return []
        normalized: list[str] = []
        for value in values:
            digits = str(value).replace("¥", "").replace("￥", "").replace("\u00a5", "").strip()
            try:
                normalized.append(f"{float(digits):.2f}")
            except ValueError:
                continue
        return normalized

    def close_visible_modal_if_present(self, frame: Frame) -> None:
        closed = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const close = root.querySelector('.ant-modal-close, .ant-drawer-close, button[aria-label="Close"]');
                if (!close) return false;
                close.click();
                return true;
            }"""
        )
        if not closed:
            self.page.keyboard.press("Escape")
        self.page.wait_for_timeout(500)

    @staticmethod
    def amount_pair_in_text(text: str, expected_apply_amount: str, expected_penalty_amount: str) -> bool:
        currency_values = re.findall(r"(?:¥|￥|\u00a5)\s*(\d+(?:\.\d{1,2})?)", text)
        raw_values = currency_values or re.findall(r"\d+(?:\.\d{1,2})?", text)
        normalized = []
        for value in raw_values:
            digits = value.replace("¥", "").replace("￥", "")
            try:
                normalized.append(f"{float(digits):.2f}")
            except ValueError:
                continue
        expected = [f"{float(expected_apply_amount):.2f}", f"{float(expected_penalty_amount):.2f}"]
        for index in range(0, max(0, len(normalized) - 1)):
            if normalized[index : index + 2] == expected:
                return True
        return False

    def click_table_row_action_by_keyword(self, frame: Frame, keyword: str, action_texts: list[str]) -> bool:
        if not keyword:
            return False
        clicked = bool(
            frame.evaluate(
                """({ keyword, actionTexts }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text.includes(keyword));
                    const row = rows[0]?.row;
                    if (!row) return false;
                    const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span, div'))
                      .map(el => {
                        const clickable = el.closest('button, a, [role="button"]') || el;
                        return {
                          el,
                          clickable,
                          text: (el.innerText || el.textContent || '').trim(),
                          disabled: Boolean(clickable.disabled),
                        };
                      })
                      .filter(item => visible(item.el) && !item.disabled);
                    for (const actionText of actionTexts) {
                      const target = nodes.find(item => item.text === actionText) ||
                        nodes.find(item => item.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, '')) ||
                        nodes.find(item => item.text.includes(actionText));
                      if (target) {
                        target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                        target.clickable.click();
                        return true;
                      }
                    }
                    return false;
                }""",
                {"keyword": keyword, "actionTexts": action_texts},
            )
        )
        self.page.wait_for_timeout(800)
        return clicked

    def fill_process_aftersale_modal(self, frame: Frame, *, remark: str, amount: str) -> None:
        modal_text = self.visible_modal_text(frame)
        assert modal_text, f"点击处理售后后未打开弹窗；page_text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        self.select_process_reason_first_option(frame)
        self.fill_modal_amount_if_present(frame, amount)
        self.fill_visible_modal_inputs_if_present(frame, remark)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)

    def fill_appeal_modal(self, frame: Frame, *, reason: str, amount: str) -> None:
        modal_text = self.visible_modal_text(frame)
        assert modal_text, f"点击发起申诉后未打开弹窗；page_text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        self.select_appeal_type_first_option(frame)
        self.fill_modal_amount_if_present(frame, amount)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)

    @staticmethod
    def near_order_text(text: str, keyword: str) -> str:
        if not keyword:
            return text
        index = text.find(keyword)
        if index < 0:
            return text
        return text[max(0, index - 500) : index + 1500]

    @staticmethod
    def order_text_after_keyword(text: str, keyword: str) -> str:
        if not keyword:
            return text
        index = text.find(keyword)
        if index < 0:
            return text
        return text[index : index + 1500]

    def fill_aftersale_modal(self, frame: Frame, *, reason: str, amount: str) -> None:
        modal_text = self.visible_modal_text(frame)
        assert modal_text, f"点击发起售后后未打开弹窗；page_text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        self.select_first_available_in_current_modal(frame)
        self.fill_modal_amount_if_present(frame, amount)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)

    def fill_aftersale_modal_without_image(self, frame: Frame, *, reason: str, amount: str) -> None:
        modal_text = self.visible_modal_text(frame)
        assert modal_text, f"点击发起售后后未打开弹窗；page_text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        self.select_first_available_in_current_modal(frame)
        self.fill_modal_amount_if_present(frame, amount)
        self.fill_visible_modal_inputs_if_present(frame, reason)

    def fill_modal_amount_if_present(self, frame: Frame, amount: str) -> None:
        frame.evaluate(
            """amount => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const inputs = Array.from(root.querySelectorAll('input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = inputs.find(item =>
                  item.placeholder.includes('金额') || item.placeholder.includes('价格') || item.placeholder.includes('退款')
                ) || inputs.find(item => item.el.getAttribute('type') === 'number');
                if (!target) return false;
                const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                setter.call(target.el, amount);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: amount }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                return true;
            }""",
            amount,
        )
        self.page.wait_for_timeout(400)

    def select_first_available_in_current_modal(self, frame: Frame) -> None:
        self.select_modal_dropdown_first_option_by_labels(frame, [], required=False, field_name="可选下拉框")

    def select_process_reason_first_option(self, frame: Frame) -> None:
        self.select_modal_dropdown_first_option_by_labels(
            frame,
            ["处理原因", "处理结果", "售后原因", "原因"],
            field_name="处理原因",
        )

    def select_appeal_type_first_option(self, frame: Frame) -> None:
        self.select_modal_dropdown_first_option_by_labels(
            frame,
            ["申诉类型", "申请类型", "类型"],
            field_name="申诉类型",
        )

    def select_modal_dropdown_first_option_by_labels(
        self,
        frame: Frame,
        labels: list[str],
        *,
        field_name: str,
        required: bool = True,
    ) -> bool:
        selected = frame.evaluate(
            """labels => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;

                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const fields = Array.from(root.querySelectorAll('.ant-form-item, [class*="formItem"], [class*="form-item"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim() }))
                  .filter(item => visible(item.el));
                const field = fields.find(item => labels.some(label => item.text.includes(label))) ||
                  fields.find(item => !labels.length && item.el.querySelector('.ant-select-selector, input'));
                if (!field) return false;

                const control = field.el.querySelector('.ant-select-selector') ||
                  field.el.querySelector('.ant-select') ||
                  field.el.querySelector('input');
                if (!control) return false;
                const r = control.getBoundingClientRect();
                const x = Math.max(r.left + 4, r.right - 12);
                const y = r.top + r.height / 2;
                const target = document.elementFromPoint(x, y) || control;
                target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
                target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
                target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
                target.click();
                return true;
            }""",
            labels,
        )
        if not selected:
            if required:
                raise AssertionError(
                    f"{field_name}下拉定位失败；应点击输入框最右侧下拉图标后选择第一项；"
                    f"modal_text={self.visible_modal_text(frame)[:1500]}"
                )
            return False
        self.page.wait_for_timeout(700)
        assert self.click_first_visible_dropdown_option(frame), (
            f"{field_name}下拉已展开但未能选中第一项；modal_text={self.visible_modal_text(frame)[:1500]}; "
            f"dropdown_options={self.visible_dropdown_options(frame)}"
        )
        self.page.wait_for_timeout(500)
        return True

    def submit_current_modal(self, frame: Frame, button_texts: list[str]) -> None:
        for button_text in button_texts:
            if self.click_current_modal_footer_button(frame, button_text):
                return
        self.confirm_open_dialog(frame)

    def click_current_modal_footer_button(self, frame: Frame, button_text: str) -> bool:
        clicked = frame.evaluate(
            """buttonText => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const footer = root.querySelector('.ant-modal-footer') || root;
                const buttons = Array.from(footer.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = buttons.find(item => item.text === buttonText) ||
                  buttons.find(item => item.text.replace(/\\s+/g, '') === buttonText.replace(/\\s+/g, '')) ||
                  buttons.find(item => item.text.includes(buttonText));
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            button_text,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def click_modal_radio_text(self, frame: Frame, text: str) -> bool:
        script = """text => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const expected = norm(text);
                const nodes = Array.from(root.querySelectorAll('label, .ant-radio-wrapper, .ant-checkbox-wrapper'))
                  .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => {
                    if (a.text === expected && b.text !== expected) return -1;
                    if (b.text === expected && a.text !== expected) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes.find(item => item.text === expected) ||
                  nodes.find(item => item.text.includes(expected));
                if (!target) return false;
                const clickable = target.el.closest('label, .ant-radio-wrapper, .ant-checkbox-wrapper') || target.el;
                const r = clickable.getBoundingClientRect();
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return Boolean(clickable.querySelector('input')?.checked) ||
                  String(clickable.className || '').includes('checked');
            }"""
        clicked = frame.evaluate(script, text)
        if not clicked:
            clicked = self.page.evaluate(script, text)
        self.page.wait_for_timeout(500)
        return bool(clicked)

    def visible_modal_has_file_input(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    if (!root) return false;
                    return Array.from(root.querySelectorAll('input[type="file"]')).length > 0 &&
                      (root.innerText || root.textContent || '').includes('上传');
                }"""
            )
        )

    def upload_single_modal_image(self, frame: Frame) -> None:
        attachment = self.delivery_attachment_path()
        file_inputs = frame.locator(".ant-modal input[type='file']")
        assert file_inputs.count() > 0, (
            "售后弹框未找到图片上传控件；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        file_inputs.first.set_input_files(str(attachment))
        self.page.wait_for_timeout(1200)

    def upload_multiple_modal_images(self, frame: Frame, count: int) -> None:
        assert 1 <= count <= 5, f"当前仅支持上传 1~5 张图片；count={count}"
        attachments = [str(self.delivery_attachment_path(index=index)) for index in range(count)]
        file_inputs = frame.locator(".ant-modal input[type='file']")
        assert file_inputs.count() > 0, (
            "售后弹框未找到图片上传控件；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        file_inputs.first.set_input_files(attachments)
        self.page.wait_for_timeout(1800)

    def delivery_attachment_path(self, index: int = 0) -> Path:
        attachment = Path(f"runtime/delivery-placeholder-{index + 1}.png")
        attachment.parent.mkdir(parents=True, exist_ok=True)
        try:
            from PIL import Image, ImageDraw

            image = Image.new("RGB", (320, 180), color=(245, 248, 255))
            draw = ImageDraw.Draw(image)
            draw.rectangle((16, 16, 304, 164), outline=(42, 94, 210), width=4)
            draw.text((32, 72), f"E2E IMAGE {index + 1}", fill=(42, 94, 210))
            image.save(attachment, format="PNG")
        except Exception:
            if not attachment.exists():
                attachment.write_bytes(
                    bytes.fromhex(
                        "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de"
                        "0000000c4944415408d763f8ffff3f0005fe02fea73581e80000000049454e44ae426082"
                    )
                )
        return attachment

    def visible_modal_validation_text(self, frame: Frame) -> str:
        text = frame.evaluate(
            """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-form-item-explain, .ant-message, .ant-notification'))
                  .filter(visible);
                return roots.map(el => (el.innerText || el.textContent || '').trim()).filter(Boolean).join('\\n');
            }"""
        )
        self.page.wait_for_timeout(500)
        return str(text)

    def current_modal_image_count(self, frame: Frame) -> int:
        count = frame.evaluate(
            """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible);
                const root = roots.pop();
                if (!root) return 0;
                return Array.from(root.querySelectorAll('img'))
                  .filter(visible)
                  .filter(el => {
                    const src = el.getAttribute('src') || '';
                    return src.startsWith('data:image') || src.startsWith('blob:') || src.startsWith('http');
                  }).length;
            }"""
        )
        return int(count or 0)

    def detail_section_image_count(self, detail_text: str, heading: str) -> int:
        section_text = self.detail_section_text(detail_text, heading)
        if not section_text:
            return 0
        return len(re.findall(r"图片", section_text))

    def assert_order_detail_contains_keywords(
        self,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
        heading: str,
        keywords: list[str],
    ) -> str:
        detail_text = self.assert_order_detail_statuses(
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        section_text = self.detail_section_text(detail_text, heading)
        assert section_text, (
            f"订单详情缺少【{heading}】区块；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={detail_text[:1500]}"
        )
        missing = [keyword for keyword in keywords if keyword not in section_text]
        assert not missing, (
            f"订单详情【{heading}】区块缺少关键字；missing={missing}; "
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; section={section_text[:1500]}"
        )
        return section_text

    def fill_visible_modal_inputs_if_present(self, frame: Frame, value: str) -> None:
        frame.evaluate(
            """value => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const controls = Array.from(root.querySelectorAll('textarea, input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                for (const el of controls) {
                  const current = String(el.value || '').trim();
                  if (current) continue;
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                }
            }""",
            value,
        )
        self.page.wait_for_timeout(500)

    @staticmethod
    def has_unfinished_sub_order_notice(text: str) -> bool:
        return any(key in text for key in ["子单未完结", "子单未完成", "存在未完结", "存在未完成", "有子单未完结"])

    def fill_modal_textarea_if_present(self, frame: Frame, value: str) -> None:
        frame.evaluate(
            """value => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const target = Array.from(root.querySelectorAll('textarea, input'))
                  .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .find(item => item.el.tagName === 'TEXTAREA' || item.placeholder.includes('说明') || item.placeholder.includes('备注') || item.placeholder.includes('原因'));
                if (!target) return false;
                const proto = target.el.tagName === 'TEXTAREA'
                  ? window.HTMLTextAreaElement.prototype
                  : window.HTMLInputElement.prototype;
                const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                setter.call(target.el, value);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                return true;
            }""",
            value,
        )
        self.page.wait_for_timeout(500)

    def record_revoke_errors(self, source_order_no: str, order_title: str) -> None:
        if not self.bug_reporter:
            return
        self.bug_reporter.record_if_errors(
            account="wanzhanggui revoke order",
            location=f"wanzhanggui revoke order api: {source_order_no}/{order_title}",
        )

    def record_order_submit_errors(self, order: ManualOrderData) -> None:
        if not self.bug_reporter:
            return
        self.bug_reporter.record_if_errors(
            account="丸掌柜手工录单",
            location=f"丸掌柜-商家管理-订单管理-手工录单：{order.source_order_no}/{order.order_title}",
        )

    def fill_manual_order_base_form(self, frame: Frame, order: ManualOrderData) -> None:
        self.fill_input_by_placeholder(frame, "请输入订单", order.source_order_no, index_from_end=False)
        self.fill_input_by_placeholder(frame, "请输入角色名", order.role_name)
        self.fill_input_by_placeholder(frame, "请输入游戏号", order.game_account)
        self.fill_input_by_placeholder(frame, "请输入登录密", order.game_password)
        self.fill_input_by_placeholder(frame, "小时", order.service_hours)
        self.fill_input_by_placeholder(frame, "请输入订单内容", order.order_title)
        self.fill_input_by_placeholder(frame, "请输入老板电话", order.boss_phone)
        self.fill_input_by_placeholder(frame, "请输入电话", order.contact_phone)
        self.fill_input_by_placeholder(frame, "请输入QQ号", order.customer_qq)
        self.fill_input_by_placeholder(frame, "请输入微信号", order.customer_wechat)
        self.fill_textarea_by_placeholder(frame, "请输入订单备注信息", f"E2E备注 {order.source_order_no}")
        self.fill_number_by_placeholder(frame, "请输入价格", order.receive_price)

        self.select_first_available_if_placeholder(frame, "请选择订单来源", field_name="订单来源")
        self.select_manual_order_game_region_service_values(frame, order)
        self.ensure_game_region_service_selected(frame)
        self.align_order_type_with_service_project(frame)
        self.select_sales_customer(frame)

    def fill_manual_order_form(self, frame: Frame, order: ManualOrderData) -> None:
        self.fill_manual_order_base_form(frame, order)
        self.ensure_push_radio(frame)

        self.fill_sub_order_row(frame, order)

    def fill_required_fields_after_submit(self, frame: Frame, order: ManualOrderData) -> None:
        self.select_sales_customer(frame)
        self.fill_input_by_placeholder(frame, "请输入订单", order.source_order_no, index_from_end=False)
        self.fill_input_by_placeholder(frame, "小时", order.service_hours)
        self.fill_input_by_placeholder(frame, "请输入订单内容", order.order_title)
        self.fill_number_by_placeholder(frame, "请输入价格", order.receive_price)
        self.select_manual_order_game_region_service_values(frame, order)
        self.ensure_game_region_service_selected(frame)
        self.fill_sub_order_row(frame, order)

    def fill_required_fields_after_submit_without_dispatch(self, frame: Frame, order: ManualOrderData) -> None:
        self.select_sales_customer(frame)
        self.fill_input_by_placeholder(frame, "请输入订单", order.source_order_no, index_from_end=False)
        self.fill_input_by_placeholder(frame, "小时", order.service_hours)
        self.fill_input_by_placeholder(frame, "请输入订单内容", order.order_title)
        self.fill_number_by_placeholder(frame, "请输入价格", order.receive_price)
        self.select_manual_order_game_region_service_values(frame, order)
        self.ensure_game_region_service_selected(frame)

    def assert_order_in_pending_accept_status(self, frame: Frame, order: ManualOrderData) -> None:
        self.search_order_by_best_identity(
            frame,
            main_order_no=order.main_order_no,
            source_order_no=order.source_order_no,
            order_title=order.order_title,
        )
        text = frame.locator("body").inner_text(timeout=5000)
        if not self.order_identity_visible(text, order):
            self.search_order_if_possible(frame, order.order_title)
            text = frame.locator("body").inner_text(timeout=5000)
        assert self.order_identity_visible(text, order), (
            f"Wanzhanggui no-dispatch order was not visible in list; "
            f"main_order_no={order.main_order_no}; source_order_no={order.source_order_no}; "
            f"order_title={order.order_title}; text={text[:1500]}"
        )
        assert "待接单" in text, (
            f"Wanzhanggui no-dispatch order did not show pending-accept status; "
            f"main_order_no={order.main_order_no}; source_order_no={order.source_order_no}; text={text[:1500]}"
        )

    @staticmethod
    def order_identity_visible(text: str, order: ManualOrderData) -> bool:
        return bool(
            (order.main_order_no and order.main_order_no in text)
            or (order.source_order_no and order.source_order_no in text)
            or (order.order_title and order.order_title in text)
        )

    def modal_field_values(self, frame: Frame) -> list[str]:
        return list(
            frame.evaluate(
                """() => Array.from(document.querySelectorAll('.ant-modal input, .ant-modal textarea, .ant-modal .ant-select-selector'))
                  .map((el, index) => {
                    const input = el;
                    const value = input.value || (el.innerText || el.textContent || '').trim();
                    const placeholder = input.getAttribute?.('placeholder') || '';
                    const r = el.getBoundingClientRect();
                    return `${index}|${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}|${placeholder}|${value}`;
                  })
                  .filter(Boolean)"""
            )
        )

    def modal_validation_messages(self, frame: Frame) -> list[str]:
        return list(
            frame.evaluate(
                """() => {
                    const selectors = [
                      '.ant-form-item-explain-error',
                      '.ant-form-item-has-error',
                      '.ant-message-notice',
                      '.ant-notification-notice',
                      '[role="alert"]',
                    ];
                    const messages = [];
                    for (const selector of selectors) {
                      for (const el of document.querySelectorAll(selector)) {
                        const text = (el.innerText || el.textContent || '').trim();
                        const r = el.getBoundingClientRect();
                        if (text && r.width > 0 && r.height > 0) messages.push(`${selector}: ${text}`);
                      }
                    }
                    return Array.from(new Set(messages));
                }"""
            )
        )

    def modal_required_field_status(self, frame: Frame) -> list[dict[str, str]]:
        return list(
            frame.evaluate(
                """() => {
                    const modal = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      }).pop();
                    if (!modal) return [];
                    const labels = Array.from(modal.querySelectorAll('.ant-form-item-required'))
                      .map(label => {
                        const text = (label.innerText || label.textContent || '').trim();
                        const item = label.closest('.ant-form-item');
                        const valueNodes = item ? Array.from(item.querySelectorAll('input, textarea, .ant-select-selector, .ant-radio-wrapper-checked')) : [];
                        const values = valueNodes.map(node => node.value || (node.innerText || node.textContent || '').trim()).filter(Boolean);
                        const error = item ? (item.querySelector('.ant-form-item-explain-error')?.innerText || '') : '';
                        return { label: text, values: values.join('|'), error };
                      });
                    return labels;
                }"""
            )
        )

    def submit_button_status(self, frame: Frame) -> list[dict[str, str]]:
        return list(
            frame.evaluate(
                """() => Array.from(document.querySelectorAll('.ant-modal button'))
                  .map(button => ({
                    text: (button.innerText || button.textContent || '').trim(),
                    disabled: String(button.disabled),
                    className: String(button.className || ''),
                  }))
                  .filter(item => item.text)"""
            )
        )

    def fill_ai_order_content(self, frame: Frame, ai_text: str) -> None:
        textarea = frame.locator("textarea[placeholder*='请粘贴订单内容']").first
        if textarea.count() == 0:
            raise AssertionError(f"未找到智能识别订单内容输入框；modal_fields={self.modal_field_values(frame)}")
        textarea.fill(ai_text)
        expect(textarea).to_have_value(ai_text, timeout=3000)
        clicked = self.click_optional_frame_buttons(frame, ["智能识别并填表"])
        assert clicked, f"未点到智能识别并填表按钮；buttons={self.submit_button_status(frame)}"
        self.page.wait_for_timeout(2500)

    def fill_sub_order_row(self, frame: Frame, order: ManualOrderData) -> None:
        self.select_sub_order_strategy(frame, order.strategy_name)
        self.select_strategy(frame, order.strategy_name)
        price_mode = self.resolve_sub_order_price_mode(order)
        price_label = "百分比发单价" if price_mode == "percent" else "固定发单价"
        price_value = self.resolve_sub_order_price_value(order)
        self.select_sub_order_price_mode(frame, order.strategy_name, price_label)
        if self.fill_embedded_sub_order_row(frame, order):
            return
        row = frame.locator(
            ".ant-modal:visible:has(input[placeholder*='请输入发单价']) table tbody tr, "
            "body:has(input[placeholder*='请输入发单价']) table tbody tr"
        ).filter(has_text=order.strategy_name).first
        if row.count() == 0 and not order.strategy_name:
            row = frame.locator(
                ".ant-modal:visible:has(input[placeholder*='请输入发单价']) table tbody tr, "
                "body:has(input[placeholder*='请输入发单价']) table tbody tr"
            ).filter(has_text=price_label).first
        if row.count() == 0:
            row = frame.locator(".ant-modal:visible table tbody tr, body table tbody tr").filter(
                has=frame.locator("input[placeholder*='请输入发单价'], input[placeholder*='价格百分比'], input[placeholder*='百分比']")
            ).first
        try:
            expect(row).to_be_visible(timeout=5000)
        except Exception as exc:
            raise AssertionError(f"未找到可填写的子单信息行；modal_fields={self.modal_field_values(frame)}") from exc

        self.fill_row_input(row, "请输入订单标题", order.sub_order_title or order.order_title)
        if price_mode == "percent":
            self.fill_row_input_any(row, ["请输入价格百分比", "价格百分比", "请输入百分比", "百分比"], price_value)
        else:
            self.fill_row_input(row, "请输入发单价", price_value)
        self.fill_row_input(row, "请输入代练时限", order.service_hours)
        self.fill_row_input(row, "请输入安全保证金", order.security_deposit)
        self.fill_row_input(row, "请输入效率保证金", order.efficiency_deposit)
        self.fill_row_input(row, "请输入联系电话", order.contact_phone)
        self.page.wait_for_timeout(800)

        missing = self.empty_sub_order_fields(frame, order)
        assert not missing, f"子单信息仍有必填项未填写：{missing}；modal_fields={self.modal_field_values(frame)}"
        self.assert_sub_order_price_mode_ready(frame, order)

    def select_sub_order_strategy(self, frame: Frame, strategy_name: str) -> None:
        if not strategy_name:
            return
        open_result = frame.evaluate(
                """strategyName => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const modal = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(visible)
                      .reverse()
                      .find(el => (el.innerText || el.textContent || '').includes('请添加子单')) ||
                      Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop();
                    if (!modal) return { ok: false, reason: 'modal not found' };
                    const rows = Array.from(modal.querySelectorAll('table tbody tr'))
                      .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0);
                    const row = rows[0]?.row;
                    if (!row) return { ok: false, reason: 'sub order row not found', text: modal.innerText || '' };
                    const cells = Array.from(row.querySelectorAll('td'));
                    let strategyIndex = 2;
                    const table = row.closest('table');
                    if (table) {
                      const headers = Array.from(table.querySelectorAll('thead th'))
                        .map((th, index) => ({ index, text: (th.innerText || th.textContent || '').replace(/\\s+/g, '') }));
                      const header = headers.find(item => item.text.includes('策略名称'));
                      if (header) strategyIndex = header.index;
                    }
                    const cell = cells[strategyIndex] || cells[2];
                    if (!cell) return { ok: false, reason: 'strategy cell not found', strategyIndex, text: row.innerText || '' };
                    const selector = cell.querySelector('.ant-select-selector') ||
                      cell.querySelector('.ant-select');
                    if (!selector) return { ok: false, reason: 'strategy selector not found', text: cell.innerText || '' };
                    if ((cell.innerText || cell.textContent || '').includes(strategyName)) return { ok: true, already: true };
                    selector.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
                    selector.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
                    selector.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
                    selector.click();
                    return { ok: true, already: false, strategyIndex, cellText: cell.innerText || cell.textContent || '' };
                }""",
                strategy_name,
            )
        assert open_result.get("ok"), (
            f"未找到子单策略名称下拉；strategy={strategy_name}; result={open_result}; "
            f"modal_fields={self.modal_field_values(frame)}"
        )
        if open_result.get("already"):
            return
        self.page.wait_for_timeout(800)
        searched = self.search_open_select_dropdown(frame, strategy_name)
        option = frame.locator(
            ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
        ).filter(has_text=strategy_name).first
        assert option.count(), (
            f"子单策略名称下拉未找到目标策略：{strategy_name}；searched={searched}; "
            f"options={self.visible_dropdown_options(frame)}; modal_fields={self.modal_field_values(frame)}"
        )
        clicked_option = self.click_open_select_option_by_text(frame, strategy_name)
        if not clicked_option:
            option.click(force=True)
        self.page.wait_for_timeout(800)
        assert self.sub_order_strategy_selected(frame, strategy_name), (
            f"子单策略名称选择后不符合预期；expected={strategy_name}; modal_fields={self.modal_field_values(frame)}"
        )

    def click_open_select_option_by_text(self, frame: Frame, option_text: str) -> bool:
        clicked = bool(
            frame.evaluate(
                """optionText => {
                    const normalize = value => String(value || '').replace(/\\s+/g, '');
                    const targetText = normalize(optionText);
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const dropdowns = Array.from(document.querySelectorAll('.ant-select-dropdown:not(.ant-select-dropdown-hidden)'))
                      .filter(visible);
                    for (const dropdown of dropdowns.reverse()) {
                      const options = Array.from(dropdown.querySelectorAll('.ant-select-item-option:not(.ant-select-item-option-disabled)'))
                        .map(el => ({ el, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                        .filter(item => item.r.width > 0 && item.r.height > 0);
                      const target = options.find(item => item.text === targetText) || options.find(item => item.text.includes(targetText));
                      if (!target) continue;
                      target.el.scrollIntoView({ block: 'center', inline: 'nearest' });
                      const r = target.el.getBoundingClientRect();
                      const x = Math.min(Math.max(r.x + r.width / 2, 1), window.innerWidth - 1);
                      const y = Math.min(Math.max(r.y + r.height / 2, 1), window.innerHeight - 1);
                      for (const type of ['mouseover', 'mousedown', 'mouseup', 'click']) {
                        target.el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }));
                      }
                      return true;
                    }
                    return false;
                }""",
                option_text,
            )
        )
        if clicked:
            self.page.wait_for_timeout(500)
            return True
        try:
            return bool(
                self.page.evaluate(
                    """optionText => {
                        const normalize = value => String(value || '').replace(/\\s+/g, '');
                        const targetText = normalize(optionText);
                        const options = Array.from(document.querySelectorAll('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled)'))
                          .map(el => ({ el, text: normalize(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                          .filter(item => item.r.width > 0 && item.r.height > 0);
                        const target = options.find(item => item.text === targetText) || options.find(item => item.text.includes(targetText));
                        if (!target) return false;
                        target.el.scrollIntoView({ block: 'center', inline: 'nearest' });
                        const r = target.el.getBoundingClientRect();
                        const x = Math.min(Math.max(r.x + r.width / 2, 1), window.innerWidth - 1);
                        const y = Math.min(Math.max(r.y + r.height / 2, 1), window.innerHeight - 1);
                        for (const type of ['mouseover', 'mousedown', 'mouseup', 'click']) {
                          target.el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }));
                        }
                        return true;
                    }""",
                    option_text,
                )
            )
        except Exception:
            return False

    def search_open_select_dropdown(self, frame: Frame, keyword: str) -> bool:
        searched = bool(
            frame.evaluate(
                """keyword => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const dropdown = Array.from(document.querySelectorAll('.ant-select-dropdown:not(.ant-select-dropdown-hidden)'))
                      .filter(visible)
                      .pop();
                    if (!dropdown) return false;
                    const input = Array.from(dropdown.querySelectorAll('input'))
                      .filter(input => visible(input) && !input.disabled && input.getAttribute('type') !== 'hidden')
                      .pop();
                    if (!input) return false;
                    input.focus();
                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    setter.call(input, keyword);
                    input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: keyword }));
                    input.dispatchEvent(new Event('change', { bubbles: true }));
                    return true;
                }""",
                keyword,
            )
        )
        if not searched:
            try:
                self.page.keyboard.type(keyword, delay=20)
                searched = True
            except Exception:
                searched = False
        self.page.wait_for_timeout(800)
        return searched

    def sub_order_strategy_selected(self, frame: Frame, strategy_name: str) -> bool:
        return bool(
            frame.evaluate(
                """strategyName => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const modal = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(visible)
                      .reverse()
                      .find(el => (el.innerText || el.textContent || '').includes('请添加子单')) ||
                      Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop();
                    if (!modal) return false;
                    const rows = Array.from(modal.querySelectorAll('table tbody tr'))
                      .filter(row => visible(row));
                    const row = rows[0];
                    return Boolean(row && (row.innerText || row.textContent || '').includes(strategyName));
                }""",
                strategy_name,
            )
        )

    def fill_embedded_sub_order_row(self, frame: Frame, order: ManualOrderData) -> bool:
        price_mode = self.resolve_sub_order_price_mode(order)
        price_value = self.resolve_sub_order_price_value(order)
        result = frame.evaluate(
            """order => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const setInput = (input, value) => {
                  input.scrollIntoView({ block: 'center', inline: 'center' });
                  input.focus();
                  const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                  setter.call(input, value);
                  input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  input.dispatchEvent(new Event('change', { bubbles: true }));
                  input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible)
                  .reverse()
                  .find(el => (el.innerText || el.textContent || '').includes('请添加子单'));
                if (!modal) return { ok: false, reason: 'embedded form not found' };
                const rows = Array.from(modal.querySelectorAll('table tbody tr'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item =>
                    item.text.includes(order.priceLabel) ||
                    item.text.includes(order.strategyName) ||
                    item.text.includes('固定发单价') ||
                    item.text.includes('百分比发单价')
                  )
                  .sort((a, b) => {
                    const aExact = order.strategyName && a.text.includes(order.strategyName) ? 0 : 1;
                    const bExact = order.strategyName && b.text.includes(order.strategyName) ? 0 : 1;
                    const aPrice = a.text.includes(order.priceLabel) ? 0 : 1;
                    const bPrice = b.text.includes(order.priceLabel) ? 0 : 1;
                    return aExact - bExact || aPrice - bPrice || a.r.y - b.r.y;
                  });
                const row = rows[0]?.row;
                if (!row) return { ok: false, reason: 'editable row not found', text: modal.innerText };

                const inputs = Array.from(row.querySelectorAll('input'))
                  .filter(input => visible(input) && !input.disabled && input.getAttribute('type') !== 'hidden')
                  .sort((a, b) => {
                    const ar = a.getBoundingClientRect();
                    const br = b.getBoundingClientRect();
                    return ar.x - br.x || ar.y - br.y;
                  });
                const byPlaceholder = label => inputs.find(input => (input.getAttribute('placeholder') || '').includes(label));
                const inputByHeader = headerText => {
                  const cells = Array.from(row.children);
                  const table = row.closest('table');
                  const headers = table ? Array.from(table.querySelectorAll('thead th')) : [];
                  const index = headers.findIndex(th => (th.innerText || th.textContent || '').includes(headerText));
                  const cell = index >= 0 ? cells[index] : null;
                  return cell ? Array.from(cell.querySelectorAll('input')).find(input => visible(input) && !input.disabled) : null;
                };
                const title = byPlaceholder('订单标题') || inputs[0];
                const price = order.priceMode === 'percent'
                  ? (byPlaceholder('价格百分比') || byPlaceholder('百分比') || inputByHeader('价格百分比') || inputs[1])
                  : (byPlaceholder('发单价') || inputByHeader('发单价') || inputs[1]);
                const hours = byPlaceholder('代练时限') || inputs[2];
                const safe = byPlaceholder('安全保证金') || inputs[3];
                const efficiency = byPlaceholder('效率保证金') || inputs[4];
                const phone = byPlaceholder('联系电话') || inputs[5];
                const pairs = [
                  [title, order.orderTitle],
                  [price, order.priceValue],
                  [hours, order.serviceHours],
                  [safe, order.securityDeposit],
                  [efficiency, order.efficiencyDeposit],
                  [phone, order.contactPhone],
                ];
                const missing = [];
                for (const [input, value] of pairs) {
                  if (!input) {
                    missing.push(value);
                    continue;
                  }
                  setInput(input, value);
                }
                return {
                  ok: missing.length === 0,
                  missing,
                  values: inputs.map(input => input.value || ''),
                  inputCount: inputs.length,
                  text: row.innerText || row.textContent || '',
                };
            }""",
            {
                "orderTitle": order.sub_order_title or order.order_title,
                "strategyName": order.strategy_name,
                "priceMode": price_mode,
                "priceLabel": "百分比发单价" if price_mode == "percent" else "固定发单价",
                "priceValue": price_value,
                "securityDeposit": order.security_deposit,
                "efficiencyDeposit": order.efficiency_deposit,
                "serviceHours": order.service_hours,
                "contactPhone": order.contact_phone,
            },
        )
        if not result.get("ok"):
            return False
        self.page.wait_for_timeout(800)
        values = [value for value in result.get("values", []) if value and value != "--"]
        assert len(values) >= 5, f"内嵌子单信息填写后仍缺少字段；result={result}"
        self.assert_sub_order_price_mode_ready(frame, order)
        return True

    def assert_sub_order_price_mode_ready(self, frame: Frame, order: ManualOrderData) -> None:
        price_mode = self.resolve_sub_order_price_mode(order)
        price_label = "百分比发单价" if price_mode == "percent" else "固定发单价"
        price_value = self.resolve_sub_order_price_value(order)
        state = frame.evaluate(
            """({ priceLabel, priceValue, priceMode }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible)
                  .reverse();
                const root = roots.find(el => {
                  const text = el.innerText || el.textContent || '';
                  const placeholders = Array.from(el.querySelectorAll('input'))
                    .map(input => input.getAttribute('placeholder') || '')
                    .join('\\n');
                  return /请添加子单|固定发单价|百分比发单价|请输入发单价|价格百分比|百分比/.test(text + '\\n' + placeholders);
                }) || document.body;
                const selectedTexts = Array.from(root.querySelectorAll('.ant-select-selection-item, .ant-select-selector'))
                  .map(el => `${el.getAttribute('title') || ''} ${el.innerText || el.textContent || ''}`.trim())
                  .filter(Boolean);
                const inputs = Array.from(root.querySelectorAll('input'))
                  .map(input => ({
                    placeholder: input.getAttribute('placeholder') || '',
                    value: input.value || input.getAttribute('value') || '',
                    visible: visible(input),
                    type: input.getAttribute('type') || '',
                  }))
                  .filter(item => item.visible && item.type !== 'hidden');
                const modeOk = selectedTexts.some(text => text.includes(priceLabel)) ||
                  (root.innerText || root.textContent || '').includes(priceLabel);
                const editableRows = Array.from(root.querySelectorAll('tbody tr'))
                  .filter(row => {
                    const text = row.innerText || row.textContent || '';
                    return /固定发单价|百分比发单价/.test(text) || row.querySelector('input[placeholder*="请输入订单标题"]');
                  });
                const row = editableRows[0];
                const inputByHeader = headerText => {
                  if (!row) return null;
                  const cells = Array.from(row.children);
                  const table = row.closest('table');
                  const headers = table ? Array.from(table.querySelectorAll('thead th')) : [];
                  const index = headers.findIndex(th => (th.innerText || th.textContent || '').includes(headerText));
                  const cell = index >= 0 ? cells[index] : null;
                  const input = cell ? Array.from(cell.querySelectorAll('input')).find(item => visible(item) && !item.disabled) : null;
                  if (!input) return null;
                  return {
                    placeholder: input.getAttribute('placeholder') || '',
                    value: input.value || input.getAttribute('value') || '',
                    visible: true,
                    type: input.getAttribute('type') || '',
                  };
                };
                const priceInput = priceMode === 'percent'
                  ? (inputs.find(item => /价格百分比|百分比/.test(item.placeholder)) || inputByHeader('价格百分比'))
                  : (inputs.find(item => /发单价/.test(item.placeholder)) || inputByHeader('发单价'));
                const numericEqual = (actual, expected) => {
                  const left = Number(String(actual || '').replace(/[^\d.]/g, ''));
                  const right = Number(String(expected || '').replace(/[^\d.]/g, ''));
                  return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) < 0.001;
                };
                return {
                  modeOk,
                  priceValueOk: Boolean(priceInput && (
                    String(priceInput.value).trim() === String(priceValue) ||
                    numericEqual(priceInput.value, priceValue)
                  )),
                  priceInput,
                  selectedTexts,
                  inputs,
                  text: (root.innerText || root.textContent || '').slice(0, 1500),
                };
            }""",
            {"priceLabel": price_label, "priceValue": price_value, "priceMode": price_mode},
        )
        assert state.get("modeOk"), (
            f"子单发单价格模式未确认到【{price_label}】，禁止提交；state={state}"
        )
        assert state.get("priceValueOk"), (
            f"子单发单价格值未确认到【{price_value}】，禁止提交；state={state}"
        )

    @staticmethod
    def resolve_sub_order_price_mode(order: ManualOrderData) -> str:
        raw = (order.dispatch_price_mode or os.getenv("E2E_S_FLOW_DISPATCH_PRICE_MODE", "percent")).strip().lower()
        if raw in {"percent", "percentage", "百分比", "百分比发单价"}:
            return "percent"
        if raw in {"fixed", "amount", "固定", "固定发单价"}:
            return "fixed"
        raise AssertionError(
            "E2E_S_FLOW_DISPATCH_PRICE_MODE 仅支持 fixed/固定发单价 或 percent/百分比发单价；"
            f"当前值：{raw}"
        )

    def resolve_sub_order_price_value(self, order: ManualOrderData) -> str:
        if self.resolve_sub_order_price_mode(order) == "percent":
            value = os.getenv(
                "E2E_S_FLOW_DISPATCH_PRICE_PERCENT",
                order.dispatch_price_percent or default_dispatch_price_percent(),
            ).strip()
            assert value.isdigit() and DISPATCH_PRICE_PERCENT_MIN <= int(value) <= DISPATCH_PRICE_PERCENT_MAX, (
                "E2E_S_FLOW_DISPATCH_PRICE_PERCENT "
                f"必须为 {DISPATCH_PRICE_PERCENT_MIN}-{DISPATCH_PRICE_PERCENT_MAX} 的整数；当前值：{value}"
            )
            return value
        if order.dispatch_price_mode:
            return order.dispatch_price.strip() or os.getenv("E2E_S_FLOW_DISPATCH_PRICE", "70").strip() or "70"
        return os.getenv("E2E_S_FLOW_DISPATCH_PRICE", order.dispatch_price).strip() or order.dispatch_price

    def select_sub_order_price_mode(self, frame: Frame, strategy_name: str, price_label: str) -> None:
        last_result = {}
        for _ in range(4):
            result = frame.evaluate(
            """({ strategyName, priceLabel }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible)
                  .reverse()
                  .find(el => /请添加子单|固定发单价|百分比发单价|请输入发单价|价格百分比/.test(el.innerText || el.textContent || ''));
                if (!modal) return { ok: false, reason: 'modal not found' };
                const rows = Array.from(modal.querySelectorAll('table tbody tr, [class*="row"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && item.text)
                  .filter(item =>
                    item.text.includes(strategyName) ||
                    item.text.includes('固定发单价') ||
                    item.text.includes('百分比发单价') ||
                    item.text.includes('请输入发单价') ||
                    item.text.includes('价格百分比')
                  );
                const row = rows[0]?.row || modal;
                const currentText = row.innerText || row.textContent || '';
                if (currentText.includes(priceLabel)) return { ok: true, already: true, text: currentText };
                const selectors = Array.from(row.querySelectorAll('.ant-select-selector, [class*="select-selector"], input[readonly]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || el.value || el.getAttribute('value') || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const selector = selectors.find(item => /固定发单价|百分比发单价/.test(item.text)) ||
                  selectors.find(item => /价格|发单/.test(item.text)) ||
                  selectors[0];
                if (!selector) return { ok: priceLabel === '固定发单价', reason: 'selector not found', text: currentText };
                selector.el.scrollIntoView({ block: 'center', inline: 'center' });
                const r = selector.el.getBoundingClientRect();
                const options = { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
                selector.el.dispatchEvent(new MouseEvent('mouseover', options));
                selector.el.dispatchEvent(new MouseEvent('mousedown', options));
                selector.el.dispatchEvent(new MouseEvent('mouseup', options));
                selector.el.click();
                return { ok: true, clicked: true, text: currentText, selectorText: selector.text };
            }""",
                {"strategyName": strategy_name, "priceLabel": price_label},
            )
            last_result = result
            if result.get("already"):
                return
            if result.get("clicked"):
                self.page.wait_for_timeout(600)
                self.click_dropdown_option_anywhere(frame, price_label)
                self.page.wait_for_timeout(800)
                current_text = str(
                    frame.evaluate(
                        """() => {
                            const visible = el => {
                              const r = el.getBoundingClientRect();
                              return r.width > 0 && r.height > 0;
                            };
                            const modal = Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop();
                            if (!modal) return document.body.innerText || '';
                            const selected = Array.from(modal.querySelectorAll('.ant-select-selection-item, .ant-select-selector'))
                              .map(el => `${el.getAttribute('title') || ''} ${el.innerText || el.textContent || ''}`)
                              .join('\\n');
                            return `${selected}\\n${modal.innerText || modal.textContent || ''}`;
                        }"""
                    )
                )
                if price_label in current_text:
                    return
        if price_label == "百分比发单价":
            current_text = ""
            for _ in range(6):
                current_text = str(
                    frame.evaluate(
                        """() => {
                            const visible = el => {
                              const r = el.getBoundingClientRect();
                              return r.width > 0 && r.height > 0;
                            };
                            const modal = Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop();
                            if (!modal) return document.body.innerText || '';
                            const selected = Array.from(modal.querySelectorAll('.ant-select-selection-item'))
                              .map(el => `${el.getAttribute('title') || ''} ${el.innerText || el.textContent || ''}`)
                              .join('\\n');
                            return `${selected}\\n${modal.innerText || modal.textContent || ''}`;
                        }"""
                    )
                )
                if price_label in current_text:
                    break
                self.page.wait_for_timeout(500)
            if not current_text:
                current_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
            assert price_label in current_text or result.get("already"), (
                f"子单表单未能切换到【{price_label}】；result={last_result}; text={current_text[:1500]}"
            )

    def fill_row_input(self, row, placeholder_part: str, value: str) -> None:
        locator = row.locator(f"input[placeholder*='{placeholder_part}']").first
        if locator.count() == 0:
            raise AssertionError(f"Sub-order row field not found: {placeholder_part}")
        locator.scroll_into_view_if_needed()
        locator.click(force=True)
        locator.press("Control+A")
        locator.type(value, delay=20)
        locator.press("Tab")
        self.page.wait_for_timeout(300)
        actual = locator.input_value(timeout=3000)
        assert actual not in {"", "--"}, f"Sub-order field was not filled: {placeholder_part}, actual={actual}"

    def fill_row_input_any(self, row, placeholder_parts: list[str], value: str) -> None:
        for placeholder_part in placeholder_parts:
            locator = row.locator(f"input[placeholder*='{placeholder_part}']").first
            if locator.count():
                locator.scroll_into_view_if_needed()
                locator.click(force=True)
                locator.press("Control+A")
                locator.type(value, delay=20)
                locator.press("Tab")
                self.page.wait_for_timeout(300)
                actual = locator.input_value(timeout=3000)
                assert actual not in {"", "--"}, (
                    f"Sub-order field was not filled: {placeholder_part}, actual={actual}"
                )
                return
        raise AssertionError(f"Sub-order row field not found: {placeholder_parts}")

    def empty_sub_order_fields(self, frame: Frame, order: ManualOrderData) -> list[dict[str, str]]:
        price_mode = self.resolve_sub_order_price_mode(order)
        return list(
            frame.evaluate(
                """order => {
                    const modals = Array.from(document.querySelectorAll('.ant-modal, body'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const modal = modals.reverse().find(el => {
                      const text = el.innerText || el.textContent || '';
                      const placeholders = Array.from(el.querySelectorAll('input')).map(input => input.getAttribute('placeholder') || '').join('\\n');
                      return text.includes('固定发单价') ||
                        text.includes('百分比发单价') ||
                        placeholders.includes('请输入发单价') ||
                        placeholders.includes('价格百分比') ||
                        placeholders.includes('百分比');
                    });
                    if (!modal) return [{ placeholder: '<modal>', value: 'missing' }];
                    const rows = Array.from(modal.querySelectorAll('table tbody tr'))
                      .filter(row => (row.innerText || row.textContent || '').includes(order.strategyName) ||
                        (row.innerText || row.textContent || '').includes('固定发单价') ||
                        (row.innerText || row.textContent || '').includes('百分比发单价') ||
                        Array.from(row.querySelectorAll('input')).some(input => {
                          const placeholder = input.getAttribute('placeholder') || '';
                          return placeholder.includes('请输入发单价') ||
                            placeholder.includes('价格百分比') ||
                            placeholder.includes('百分比');
                        }));
                    const row = rows[0];
                    if (!row) return [{ placeholder: '<row>', value: 'missing' }];
                    const priceLabels = order.priceMode === 'percent'
                      ? ['请输入价格百分比', '价格百分比', '请输入百分比', '百分比']
                      : ['请输入发单价', '发单价'];
                    const required = [
                      ['请输入订单标题', '订单标题'],
                      priceLabels,
                      ['请输入代练时限', '代练时限'],
                      ['请输入安全保证金', '安全保证金'],
                      ['请输入效率保证金', '效率保证金'],
                      ['请输入联系电话', '联系电话'],
                    ];
                    const inputs = Array.from(row.querySelectorAll('input'));
                    return required.flatMap(labels => {
                      const input = inputs.find(item => labels.some(label => (item.getAttribute('placeholder') || '').includes(label)));
                      if (!input) return [{ placeholder: labels.join('/'), value: '<missing input>' }];
                      const value = input.value || '';
                      return value && value !== '--' ? [] : [{ placeholder: labels.join('/'), value }];
                    });
                }""",
                {"strategyName": order.strategy_name, "priceMode": price_mode},
            )
        )

    def select_strategy(self, frame: Frame, strategy_name: str) -> None:
        selectors = frame.locator(".ant-modal .ant-select-selector").filter(has_text="E2E推单策略")
        if selectors.count() == 0:
            return
        selector = selectors.first
        if strategy_name and strategy_name in selector.inner_text(timeout=5000):
            return
        selector.click(force=True)
        self.page.wait_for_timeout(800)
        searched = self.search_open_strategy_dropdown(frame, strategy_name)
        option = frame.locator(
            ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
        ).filter(has_text=strategy_name).first
        assert option.count(), (
            f"策略名称下拉未找到目标策略：{strategy_name}；searched={searched}; "
            f"options={self.visible_dropdown_options(frame)}; modal_fields={self.modal_field_values(frame)}"
        )
        option.click(force=True)
        self.page.wait_for_timeout(800)
        selected_text = selector.inner_text(timeout=5000)
        assert strategy_name in selected_text, (
            f"策略名称选择后不符合预期；expected={strategy_name}; selected={selected_text}; "
            f"modal_fields={self.modal_field_values(frame)}"
        )

    def search_open_strategy_dropdown(self, frame: Frame, strategy_name: str) -> bool:
        searched = bool(
            frame.evaluate(
                """strategyName => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const dropdown = Array.from(document.querySelectorAll('.ant-select-dropdown:not(.ant-select-dropdown-hidden)'))
                      .filter(visible)
                      .pop();
                    const roots = [dropdown, document].filter(Boolean);
                    for (const root of roots) {
                      const inputs = Array.from(root.querySelectorAll('input'))
                        .filter(input => visible(input) && !input.disabled && input.getAttribute('type') !== 'hidden');
                      const input = inputs[inputs.length - 1];
                      if (!input) continue;
                      input.focus();
                      const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                      setter.call(input, strategyName);
                      input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: strategyName }));
                      input.dispatchEvent(new Event('change', { bubbles: true }));
                      return true;
                    }
                    return false;
                }""",
                strategy_name,
            )
        )
        self.page.wait_for_timeout(800)
        return searched

    def select_first_available_if_placeholder(self, frame: Frame, text: str, field_name: str | None = None) -> None:
        selectors = frame.locator(".ant-modal .ant-select-selector").filter(has_text=text)
        if selectors.count() == 0:
            return
        selector = selectors.first
        current_text = selector.inner_text(timeout=5000).strip()
        if (
            current_text
            and not current_text.isdigit()
            and "请选择" not in current_text
            and "璇烽€夋嫨" not in current_text
            and text not in current_text
        ):
            return
        selector.click(force=True)
        self.page.wait_for_timeout(800)
        options = self.visible_dropdown_options(frame)
        if field_name == "订单来源":
            self.record_order_source_options(options)
            self.assert_order_source_options_available(options)
        clicked = self.click_first_visible_dropdown_option(frame)
        if clicked:
            self.page.wait_for_timeout(800)

    def ensure_game_region_service_selected(self, frame: Frame) -> None:
        for _ in range(3):
            status = self.manual_order_game_region_service_status(frame)
            missing = status.get("missing", [])
            if not missing:
                return
            for field in missing:
                self.select_manual_order_game_region_field(frame, str(field))
            self.page.wait_for_timeout(800)
        status = self.manual_order_game_region_service_status(frame)
        assert not status.get("missing"), (
            f"手工录单游戏/区/服/服务项目选择未生效；status={status}; "
            f"validation_messages={self.modal_validation_messages(frame)}"
        )

    def select_manual_order_game_region_service_values(self, frame: Frame, order: ManualOrderData) -> None:
        target_values = {
            "游戏": order.game_name,
            "区": order.game_region,
            "服": order.game_server,
            "服务项目": order.service_project,
        }
        for field, value in target_values.items():
            if not value:
                continue
            selected = self.select_manual_order_game_region_field_value(frame, field, value)
            assert selected, (
                f"手工录单未能选择{field}={value}；status={self.manual_order_game_region_service_status(frame)}; "
                f"options={self.visible_dropdown_options(frame)}; validation_messages={self.modal_validation_messages(frame)}"
            )
            self.page.wait_for_timeout(900)

    def select_manual_order_game_region_field_value(self, frame: Frame, field: str, value: str) -> bool:
        if self.select_manual_order_game_region_field_value_by_id(frame, field, value):
            return True
        opened = frame.evaluate(
            """({ field }) => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return { ok: false, reason: 'no visible modal' };
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map((el, index) => ({
                    el,
                    index,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                const byY = new Map();
                for (const item of selectors) {
                  const key = Math.round(item.r.y / 8) * 8;
                  byY.set(key, [...(byY.get(key) || []), item]);
                }
                const rows = Array.from(byY.values()).map(items => items.sort((a, b) => a.r.x - b.r.x));
                const row = rows.find(items => {
                  if (items.length < 4) return false;
                  const text = items.map(item => item.text).join('|');
                  return /王者|WeGame|默认服|游戏|服务项目|区|服/.test(text);
                });
                if (!row) return { ok: false, reason: 'game row not found', selectors: selectors.map(item => `${item.index}:${item.text}`) };
                const index = { '游戏': 0, '区': 1, '服': 2, '服务项目': 3 }[field];
                const selector = row[index];
                if (!selector) return { ok: false, reason: `selector not found: ${field}`, row: row.map(item => item.text) };
                selector.el.click();
                return { ok: true, before: selector.text, row: row.map(item => item.text) };
            }""",
            {"field": field},
        )
        if not opened.get("ok"):
            return False
        self.page.wait_for_timeout(700)
        clicked = self.click_dropdown_option_by_text(frame, value)
        self.page.wait_for_timeout(700)
        return clicked

    def select_manual_order_game_region_field_value_by_id(self, frame: Frame, field: str, value: str) -> bool:
        selector_by_field = {
            "游戏": "#gameId",
            "区": "#gameRegion",
            "服": "#gameServer",
            "服务项目": "#gameBisServeId",
        }
        selector = selector_by_field.get(field, "")
        if not selector:
            return False
        locator = frame.locator(selector)
        if locator.count() == 0:
            return False
        try:
            locator.first.click(force=True, timeout=3000)
            self.page.wait_for_timeout(500)
            if field in {"游戏", "服务项目"}:
                try:
                    locator.first.fill(value, timeout=3000)
                    self.page.wait_for_timeout(500)
                except Exception:
                    pass
            clicked = self.click_dropdown_option_by_text(frame, value)
            if clicked:
                self.page.wait_for_timeout(700)
                return True
        except Exception:
            return False
        return False

    def click_dropdown_option_by_text(self, frame: Frame, option_text: str) -> bool:
        clicked = frame.evaluate(
            """optionText => {
                const normalized = text => String(text || '').replace(/\\s+/g, '');
                const targetText = normalized(optionText);
                const options = Array.from(document.querySelectorAll(
                    '.ant-select-dropdown:not(.ant-select-dropdown-hidden) ' +
                    '.ant-select-item-option:not(.ant-select-item-option-disabled), ' +
                    '.ant-cascader-menu-item, [role="option"]'
                )).map(el => {
                    const textEl = el.querySelector('.ant-select-item-option-content') || el;
                    const text = (textEl.innerText || textEl.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return { el, text, normalizedText: normalized(text), r };
                }).filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && item.text !== 'No data' && !item.text.includes('请选择'));
                const exact = options.find(item => item.normalizedText === targetText);
                const fuzzy = options.find(item =>
                  item.normalizedText.includes(targetText) || targetText.includes(item.normalizedText)
                );
                const target = exact || fuzzy;
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            option_text,
        )
        if not clicked:
            self.page.keyboard.press("Escape")
            self.page.wait_for_timeout(300)
        return bool(clicked)

    def manual_order_game_region_service_status(self, frame: Frame) -> dict:
        return dict(
            frame.evaluate(
                """() => {
                    const invalid = text => {
                      const value = String(text || '').replace(/\\s+/g, '').trim();
                      return !value ||
                        /^\\d+$/.test(value) ||
                        value.includes('请选择') ||
                        value.includes('游戏') ||
                        value === '区' ||
                        value === '服' ||
                        value.includes('服务项目');
                    };
                    const modal = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      }).pop();
                    if (!modal) return { ok: false, missing: ['modal'], reason: 'no visible modal' };
                    const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                      .map((el, index) => ({
                        index,
                        text: (el.innerText || el.textContent || '').trim(),
                        r: el.getBoundingClientRect(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                    const byY = new Map();
                    for (const item of selectors) {
                      const key = Math.round(item.r.y / 8) * 8;
                      byY.set(key, [...(byY.get(key) || []), item]);
                    }
                    const rows = Array.from(byY.values()).map(items => items.sort((a, b) => a.r.x - b.r.x));
                    const row = rows.find(items => {
                      if (items.length < 4) return false;
                      const text = items.map(item => item.text).join('|');
                      return /王者|安卓|国服|游戏|服务项目|区|服/.test(text);
                    });
                    if (!row) {
                      return {
                        ok: false,
                        missing: ['游戏', '区', '服', '服务项目'],
                        selectors: selectors.map(item => `${item.index}:${item.text}`),
                      };
                    }
                    const fields = ['游戏', '区', '服', '服务项目'];
                    const values = {};
                    const missing = [];
                    for (let i = 0; i < fields.length; i += 1) {
                      const value = row[i]?.text || '';
                      values[fields[i]] = value;
                      if (!row[i] || invalid(value)) missing.push(fields[i]);
                    }
                    return { ok: missing.length === 0, missing, values, row: row.map(item => `${item.index}:${item.text}`) };
                }"""
            )
        )

    def select_manual_order_game_region_field(self, frame: Frame, field: str) -> bool:
        opened = frame.evaluate(
            """field => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return { ok: false, reason: 'no visible modal' };
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map((el, index) => ({
                    el,
                    index,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                const byY = new Map();
                for (const item of selectors) {
                  const key = Math.round(item.r.y / 8) * 8;
                  byY.set(key, [...(byY.get(key) || []), item]);
                }
                const rows = Array.from(byY.values()).map(items => items.sort((a, b) => a.r.x - b.r.x));
                const row = rows.find(items => {
                  if (items.length < 4) return false;
                  const text = items.map(item => item.text).join('|');
                  return /王者|安卓|国服|游戏|服务项目|区|服/.test(text);
                });
                if (!row) return { ok: false, reason: 'game row not found', selectors: selectors.map(item => `${item.index}:${item.text}`) };
                const index = { '游戏': 0, '区': 1, '服': 2, '服务项目': 3 }[field];
                const selector = row[index];
                if (!selector) return { ok: false, reason: `selector not found: ${field}`, row: row.map(item => item.text) };
                selector.el.click();
                return { ok: true, index: selector.index, before: selector.text };
            }""",
            field,
        )
        if not opened.get("ok"):
            return False
        self.page.wait_for_timeout(1000)
        clicked = self.click_first_visible_dropdown_option(frame)
        if clicked:
            self.page.wait_for_timeout(800)
        return clicked

    def visible_dropdown_options(self, frame: Frame) -> list[str]:
        options = frame.evaluate(
            """() => Array.from(document.querySelectorAll(
                '.ant-select-dropdown:not(.ant-select-dropdown-hidden) ' +
                '.ant-select-item-option:not(.ant-select-item-option-disabled) .ant-select-item-option-content'
            )).map(el => {
                const text = (el.innerText || el.textContent || '').trim();
                const r = el.getBoundingClientRect();
                return { text, visible: r.width > 0 && r.height > 0 };
            }).filter(item => item.visible)
              .map(item => item.text)
              .filter(text => text && text !== 'No data' && !text.includes('请选择') && !/^\\d+$/.test(text))"""
        )
        return list(dict.fromkeys(options))

    def record_order_source_options(self, options: list[str]) -> None:
        ORDER_SOURCE_SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
        history = []
        if ORDER_SOURCE_SNAPSHOT_PATH.exists():
            try:
                history = json.loads(ORDER_SOURCE_SNAPSHOT_PATH.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                history = []
        if not isinstance(history, list):
            history = []
        history.append(
            {
                "timestamp": datetime.now().isoformat(timespec="seconds"),
                "account": "19147509308",
                "location": "丸掌柜-商家管理-订单管理-手工录单-订单来源",
                "url": self.page.url,
                "options": options,
                "options_count": len(options),
            }
        )
        ORDER_SOURCE_SNAPSHOT_PATH.write_text(
            json.dumps(history[-50:], ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    def assert_order_source_options_available(self, current_options: list[str]) -> None:
        if current_options:
            return
        previous = self.last_non_empty_order_source_snapshot()
        previous_text = "无历史非空快照"
        if previous:
            previous_text = (
                f"{previous.get('timestamp')}，账号 {previous.get('account')}，"
                f"选项 {previous.get('options')}"
            )
        raise AssertionError(
            "订单来源下拉当前没有可选数据。"
            f"当前账号：19147509308；当前位置：丸掌柜-商家管理-订单管理-手工录单-订单来源；"
            f"当前选项：{current_options}；"
            f"上一条非空快照：{previous_text}；"
            f"历史探针证据：{ORDER_SOURCE_HISTORICAL_EVIDENCE}。"
            "如果同一环境此前有“拼多多”等订单来源、现在为空，应记录为前后数据不一致，优先排查后台基础数据。"
        )

    def last_non_empty_order_source_snapshot(self) -> dict | None:
        if not ORDER_SOURCE_SNAPSHOT_PATH.exists():
            return None
        try:
            history = json.loads(ORDER_SOURCE_SNAPSHOT_PATH.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            return None
        if not isinstance(history, list):
            return None
        for item in reversed(history):
            if isinstance(item, dict) and item.get("options"):
                return item
        return None

    def click_first_visible_dropdown_option(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const optionContents = Array.from(document.querySelectorAll(
                        '.ant-select-dropdown:not(.ant-select-dropdown-hidden) ' +
                        '.ant-select-item-option:not(.ant-select-item-option-disabled) .ant-select-item-option-content'
                    )).map(el => {
                        const option = el.closest('.ant-select-item-option') || el;
                        const text = (el.innerText || el.textContent || '').trim();
                        const r = el.getBoundingClientRect();
                        return { option, text, r };
                    }).filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text && item.text !== 'No data' && !item.text.includes('请选择') && !item.text.includes('璇烽€夋嫨'))
                      .filter(item => !/^\\d+$/.test(item.text));
                    const target = optionContents[0];
                    if (!target) return false;
                    target.option.click();
                    return true;
                }"""
            )
        )

    def select_sales_customer(self, frame: Frame) -> None:
        return
        selected = frame.evaluate(
            """() => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return { ok: false, reason: '鏈壘鍒板脊绐? };
                const labels = Array.from(modal.querySelectorAll('label, .ant-form-item-label, div, span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.text === '閿€鍞鏈? && item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.r.y - b.r.y);
                const label = labels[labels.length - 1];
                if (!label) return { ok: false, reason: '鏈壘鍒伴攢鍞鏈嶆爣绛? };
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.r.y >= label.r.y - 12 && item.r.y <= label.r.y + 40)
                  .sort((a, b) => a.r.x - b.r.x);
                const selector = selectors[0];
                if (!selector) return { ok: false, reason: '鏈壘鍒伴攢鍞鏈嶄笅鎷? };
                if (selector.text && !selector.text.includes('请选择') && !selector.text.includes('璇烽€夋嫨')) return { ok: true, already: selector.text };
                selector.el.click();
                return { ok: true, opened: true };
            }"""
        )
        if not selected.get("ok"):
            raise AssertionError(f"閿€鍞鏈嶄笅鎷夊畾浣嶅け璐ワ細{selected}")
        if selected.get("already"):
            return
        self.page.wait_for_timeout(1000)
        clicked = frame.evaluate(
            """() => {
                const options = Array.from(document.querySelectorAll(
                    '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled)'
                )).map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                })).filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && item.text !== 'No data' && !item.text.includes('请选择') && !item.text.includes('璇烽€夋嫨'));
                const target = options[0];
                if (!target) return { ok: false, options: options.map(item => item.text) };
                target.el.click();
                return { ok: true, selected: target.text };
            }"""
        )
        self.page.wait_for_timeout(1000)
        if not clicked.get("ok"):
            self.page.keyboard.press("Escape")
            self.page.wait_for_timeout(500)

    def select_service_project(self, frame: Frame) -> None:
        status = self.select_service_project_by_select_index(frame)
        if not status.get("ok"):
            print(f"[E2E order] service project was not selected; status={status}")

    def select_service_project_by_select_index(self, frame: Frame) -> dict:
        selectors = frame.locator(".ant-modal .ant-select-selector")
        if selectors.count() < 6:
            return {"ok": False, "reason": f"寮圭獥涓嬫媺鏁伴噺涓嶈冻锛屽綋鍓嶆暟閲忥細{selectors.count()}"}
        service_selector = selectors.nth(5)
        current_text = service_selector.inner_text(timeout=5000).strip()
        if current_text and current_text not in {"0", "服务项目", "鏈嶅姟椤圭洰"} and "请选择" not in current_text and "璇烽€夋嫨" not in current_text:
            return {"ok": True, "already": current_text}

        service_selector.scroll_into_view_if_needed()
        service_selector.click(force=True)
        self.page.wait_for_timeout(1500)
        clicked = self.click_first_visible_dropdown_option(frame)
        if clicked:
            selected_text = service_selector.inner_text(timeout=5000).strip()
            return {"ok": True, "selected": selected_text}

        return {
            "ok": False,
            "reason": "鏈嶅姟椤圭洰涓嬫媺鎵撳紑鍚庢病鏈夊彲鐐瑰嚮閫夐」",
            "current": current_text,
            "dropdown_options": self.visible_dropdown_options(frame),
        }

    def align_order_type_with_service_project(self, frame: Frame) -> None:
        project_text = self.service_project_text(frame)
        if "?" in project_text:
            self.click_modal_radio_by_text(frame, "??")
        else:
            self.click_modal_radio_by_text(frame, "??")
    def service_project_text(self, frame: Frame) -> str:
        selectors = frame.locator(".ant-modal .ant-select-selector")
        if selectors.count() < 6:
            return ""
        return selectors.nth(5).inner_text(timeout=5000).strip()

    def click_modal_radio_by_text(self, frame: Frame, text: str) -> None:
        frame.evaluate(
            """text => {
                const labels = Array.from(document.querySelectorAll('.ant-modal label, .ant-radio-wrapper'));
                const target = labels.find(el => (el.innerText || el.textContent || '').trim().includes(text));
                if (target) target.click();
            }""",
            text,
        )
        self.page.wait_for_timeout(500)

    def select_game_service_project_by_row_position(self, frame: Frame) -> dict:
        opened = frame.evaluate(
            """() => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return { ok: false, reason: '鏈壘鍒板脊绐? };
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map((el, index) => ({ el, index, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                const gameRow = selectors.filter(item =>
                    item.text === '鐜嬭€? || item.text === '瀹夊崜QQ' || item.text === '鍥芥湇' || item.text === '鏈嶅姟椤圭洰' || /^\\d+$/.test(item.text)
                );
                const byY = new Map();
                for (const item of gameRow) {
                  const key = Math.round(item.r.y / 8) * 8;
                  byY.set(key, [...(byY.get(key) || []), item]);
                }
                const rows = Array.from(byY.values()).map(items => items.sort((a, b) => a.r.x - b.r.x));
                const row = rows.find(items => items.length >= 4 && items.some(item => item.text === '鐜嬭€?));
                if (!row) return { ok: false, reason: '鏈壘鍒版父鎴忓尯鏈嶆湇鍔￠」鐩', selectors: selectors.map(item => `${item.index}:${item.text}`) };
                const selector = row[3];
                if (!selector) return { ok: false, reason: '鏈壘鍒版湇鍔￠」鐩笅鎷?, row: row.map(item => item.text) };
                if (selector.text && !selector.text.includes('鏈嶅姟椤圭洰') && !/^\\d+$/.test(selector.text)) {
                  return { ok: true, already: selector.text };
                }
                selector.el.click();
                return { ok: true, opened: true, before: selector.text, row: row.map(item => item.text) };
            }"""
        )
        if not opened.get("ok") or opened.get("already"):
            return opened
        self.page.wait_for_timeout(1000)
        clicked = frame.evaluate(
            """() => {
                const optionContents = Array.from(document.querySelectorAll(
                    '.ant-select-dropdown:not(.ant-select-dropdown-hidden) ' +
                    '.ant-select-item-option:not(.ant-select-item-option-disabled) .ant-select-item-option-content'
                )).map(el => {
                    const option = el.closest('.ant-select-item-option') || el;
                    const text = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return { option, text, r };
                }).filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && item.text !== 'No data' && !item.text.includes('请选择') && !item.text.includes('璇烽€夋嫨'))
                  .filter(item => !/^\\d+$/.test(item.text));
                const target = optionContents[0];
                if (!target) return { ok: false, options: optionContents.map(item => item.text) };
                target.option.click();
                return { ok: true, selected: target.text };
            }"""
        )
        self.page.wait_for_timeout(1000)
        return clicked

    def select_labeled_select_first_visible_option(self, frame: Frame, label_text: str) -> dict:
        opened = frame.evaluate(
            """labelText => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return { ok: false, reason: '鏈壘鍒板脊绐? };
                const labels = Array.from(modal.querySelectorAll('label, .ant-form-item-label, div, span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.text === labelText && item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                const label = labels[labels.length - 1];
                if (!label) return { ok: false, reason: `鏈壘鍒版爣绛?${labelText}` };
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.r.y >= label.r.y - 12 && item.r.y <= label.r.y + 48)
                  .sort((a, b) => a.r.x - b.r.x);
                const selector = selectors[0];
                if (!selector) return { ok: false, reason: `鏈壘鍒?${labelText} 涓嬫媺`, label };
                if (selector.text && !selector.text.includes(labelText) && !selector.text.includes('请选择') && !selector.text.includes('璇烽€夋嫨') && !/^\\d+$/.test(selector.text)) {
                    return { ok: true, already: selector.text };
                }
                selector.el.click();
                return { ok: true, opened: true, before: selector.text };
            }""",
            label_text,
        )
        if not opened.get("ok") or opened.get("already"):
            return opened
        self.page.wait_for_timeout(1000)
        clicked = frame.evaluate(
            """() => {
                const options = Array.from(document.querySelectorAll(
                    '.ant-select-dropdown:not(.ant-select-dropdown-hidden) ' +
                    '.ant-select-item-option:not(.ant-select-item-option-disabled) .ant-select-item-option-content'
                )).map(el => {
                    const option = el.closest('.ant-select-item-option') || el;
                    const text = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return { option, text, r };
                }).filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && item.text !== 'No data' && !item.text.includes('请选择') && !item.text.includes('璇烽€夋嫨'))
                  .filter(item => !/^\\d+$/.test(item.text));
                const target = options[0];
                if (!target) return { ok: false, options: options.map(item => item.text) };
                target.option.click();
                return { ok: true, selected: target.text };
            }"""
        )
        self.page.wait_for_timeout(1000)
        return clicked

    def ensure_push_radio(self, frame: Frame) -> None:
        frame.evaluate(
            """() => {
                const labels = Array.from(document.querySelectorAll('.ant-modal label, .ant-radio-wrapper'));
                const target = labels.find(el => (el.innerText || el.textContent || '').includes('鎺ㄥ崟'));
                if (target) target.click();
            }"""
        )
        self.page.wait_for_timeout(500)

    def ensure_no_push_radio(self, frame: Frame) -> None:
        clicked = frame.evaluate(
            """() => {
                const labels = Array.from(document.querySelectorAll('.ant-modal label, .ant-radio-wrapper'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = labels.find(item => item.text === '不推单') ||
                  labels.find(item => item.text.includes('不推单'));
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        assert clicked, (
            f"手工录单弹窗未找到【不推单】选项；"
            f"text={self.visible_modal_text(frame)[:1500]}; fields={self.modal_field_values(frame)}"
        )
        self.page.wait_for_timeout(500)

    def assert_no_dispatch_fields_visible(self, frame: Frame) -> None:
        visible_fields = frame.evaluate(
            """() => {
                const modal = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  }).pop();
                if (!modal) return [];
                const blockedTexts = ['推单策略', '发单价', '子单'];
                return Array.from(modal.querySelectorAll('input, textarea, label, th, td, div, span'))
                  .map(el => ({ text: (el.innerText || el.textContent || '').trim(), placeholder: el.getAttribute?.('placeholder') || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .map(item => item.text || item.placeholder)
                  .filter(value => value && blockedTexts.some(text => value.includes(text)));
            }"""
        )
        assert not visible_fields, (
            f"选择【不推单】后仍出现子单/推单字段，可能会生成子单；"
            f"fields={visible_fields}; modal_text={self.visible_modal_text(frame)[:1500]}"
        )

    def visible_modal_text(self, frame: Frame) -> str:
        script = """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    return root ? (root.innerText || root.textContent || '') : '';
                }"""
        text = str(frame.evaluate(script))
        if text:
            return text
        return str(self.page.evaluate(script))

    def has_visible_modal(self, frame: Frame) -> bool:
        script = """() => Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                    .some(el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    })"""
        try:
            if bool(frame.evaluate(script)):
                return True
        except Exception:
            pass
        try:
            return bool(self.page.evaluate(script))
        except Exception:
            return False

    def fill_input_by_placeholder(
        self,
        frame: Frame,
        placeholder_part: str,
        value: str,
        index_from_end: bool = False,
    ) -> None:
        locators = frame.locator(f".ant-modal input[placeholder*='{placeholder_part}']")
        if locators.count() == 0:
            return
        locator = locators.nth(locators.count() - 1) if index_from_end else locators.first
        locator.fill(value)
        expect(locator).to_have_value(value, timeout=3000)

    def fill_textarea_by_placeholder(self, frame: Frame, placeholder_part: str, value: str) -> None:
        locators = frame.locator(f".ant-modal textarea[placeholder*='{placeholder_part}']")
        if locators.count() == 0:
            return
        locator = locators.first
        locator.fill(value)
        expect(locator).to_have_value(value, timeout=3000)

    def fill_number_by_placeholder(self, frame: Frame, placeholder_part: str, value: str) -> None:
        locators = frame.locator(f".ant-modal input[placeholder*='{placeholder_part}']")
        if locators.count() == 0:
            return
        locator = locators.first
        locator.fill(value)
        expect(locator).to_have_value(value, timeout=3000)

    def fill_bottom_input(self, frame: Frame, placeholder_part: str, value: str) -> None:
        locators = frame.locator(f".ant-modal input[placeholder*='{placeholder_part}']")
        if locators.count() == 0:
            return
        locator = locators.nth(locators.count() - 1)
        locator.fill(value)
        expect(locator).to_have_value(value, timeout=3000)

    def fill_bottom_number(self, frame: Frame, placeholder_part: str, value: str) -> None:
        self.fill_bottom_input(frame, placeholder_part, value)

    def fill_sub_order_number(self, frame: Frame, placeholder_part: str, value: str) -> None:
        locators = frame.locator(f".ant-modal input[placeholder*='{placeholder_part}']")
        if locators.count() == 0:
            return
        locator = locators.nth(locators.count() - 1)
        locator.scroll_into_view_if_needed()
        locator.click(force=True)
        locator.press("Control+A")
        locator.fill(value)
        locator.press("Tab")
        self.page.wait_for_timeout(300)
        actual_value = locator.input_value(timeout=3000)
        if actual_value in {"", "--"}:
            frame.evaluate(
                """({ placeholderPart, value }) => {
                    const inputs = Array.from(document.querySelectorAll('.ant-modal input'))
                      .filter(input => (input.getAttribute('placeholder') || '').includes(placeholderPart));
                    const input = inputs[inputs.length - 1];
                    if (!input) return false;
                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    setter.call(input, value);
                    input.dispatchEvent(new Event('input', { bubbles: true }));
                    input.dispatchEvent(new Event('change', { bubbles: true }));
                    input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"placeholderPart": placeholder_part, "value": value},
            )
            self.page.wait_for_timeout(300)
        assert locator.input_value(timeout=3000) not in {"", "--"}, (
            f"Sub-order field was not filled: {placeholder_part}, actual={locator.input_value(timeout=3000)}"
        )

    def click_frame_button(self, frame: Frame, text: str) -> None:
        button = frame.locator("button:visible").filter(has_text=text).first
        try:
            expect(button).to_be_visible(timeout=5000)
            button.click(force=True)
        except Exception as exc:
            body_text = frame.locator("body").inner_text(timeout=5000)
            raise AssertionError(f"未点到订单 iframe 按钮 {text}，当前文本：{body_text[:1200]}") from exc
        self.page.wait_for_timeout(1000)

    def click_optional_frame_buttons(self, frame: Frame, texts: list[str]) -> bool:
        for text in texts:
            try:
                self.click_frame_button(frame, text)
                return True
            except AssertionError:
                continue
        return False

    def click_optional_frame_text(self, frame: Frame, text: str) -> bool:
        clicked = frame.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('button, span, div, a'));
                const target = nodes
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .find(item => item.value === text || item.value.includes(text));
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            text,
        )
        return bool(clicked)

    def search_order_if_possible(self, frame: Frame, keyword: str) -> None:
        if self.fill_input_near_label(frame, "来源单号", keyword):
            self.click_optional_frame_text(frame, "查 询") or self.click_optional_frame_text(frame, "查询")
            self.page.wait_for_timeout(2000)
            return
        if self.fill_first_filter_input(frame, keyword):
            self.click_optional_frame_text(frame, "查 询") or self.click_optional_frame_text(frame, "查询")
            self.page.wait_for_timeout(2000)
            return
        inputs = frame.locator("input")
        for index in range(inputs.count()):
            locator = inputs.nth(index)
            try:
                placeholder = locator.get_attribute("placeholder") or ""
                if any(key in placeholder for key in ["来源单号", "订单标题", "订单编号", "关键字"]):
                    locator.fill(keyword)
                    self.click_optional_frame_text(frame, "查 询") or self.click_optional_frame_text(frame, "查询")
                    self.page.wait_for_timeout(2000)
                    return
            except Exception:
                continue

    def fill_first_filter_input(self, frame: Frame, value: str) -> bool:
        return bool(
            frame.evaluate(
                """value => {
                    const buttons = Array.from(document.querySelectorAll('button'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0);
                    const queryButton = buttons.find(item => item.text === '查 询' || item.text === '查询');
                    const maxY = queryButton ? queryButton.r.bottom + 12 : 260;
                    const inputs = Array.from(document.querySelectorAll('input'))
                      .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.r.y < maxY)
                      .filter(item => !item.el.disabled && item.el.getAttribute('type') !== 'hidden')
                      .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                    const target = inputs.find(item => /订单|单号|标题|来源/.test(item.placeholder)) || inputs[0];
                    if (!target) return false;
                    target.el.focus();
                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    setter.call(target.el, value);
                    target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    target.el.dispatchEvent(new Event('change', { bubbles: true }));
                    target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                value,
            )
        )

    def fill_input_near_label(self, frame: Frame, label_text: str, value: str) -> bool:
        return bool(
            frame.evaluate(
                """({ labelText, value }) => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const setValue = input => {
                      input.focus();
                      const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                      setter.call(input, value);
                      input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                      input.dispatchEvent(new Event('change', { bubbles: true }));
                      input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                      return true;
                    };
                    const placeholderInput = Array.from(document.querySelectorAll('input'))
                      .find(input => {
                        const placeholder = input.getAttribute('placeholder') || '';
                        return visible(input) &&
                          !input.disabled &&
                          input.getAttribute('type') !== 'hidden' &&
                          placeholder.replace(/\\s+/g, '').includes(labelText.replace(/\\s+/g, ''));
                      });
                    if (placeholderInput) return setValue(placeholderInput);
                    const nodes = Array.from(document.querySelectorAll('label, div, span'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => {
                        const text = item.text.replace(/\\s+/g, '');
                        const expected = labelText.replace(/\\s+/g, '');
                        return (text === expected || text === expected + '：' || text === expected + ':') &&
                          item.r.width > 0 &&
                          item.r.height > 0;
                      })
                      .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                    const label = nodes[0];
                    if (!label) return false;
                    const container = label.el.closest('.ant-form-item, [class*="form-item"], .ant-row, [class*="filter"], [class*="search"]');
                    const containedInput = container
                      ? Array.from(container.querySelectorAll('input')).find(input => visible(input) && !input.disabled && input.getAttribute('type') !== 'hidden')
                      : null;
                    if (containedInput) return setValue(containedInput);
                    const inputs = Array.from(document.querySelectorAll('input'))
                      .map(el => ({ el, r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => !item.el.disabled && item.el.getAttribute('type') !== 'hidden')
                      .filter(item =>
                        (Math.abs(item.r.y - label.r.y) < 44 && item.r.x > label.r.x) ||
                        (item.r.y >= label.r.y && item.r.y - label.r.y < 72 && Math.abs(item.r.x - label.r.x) < 260)
                      )
                      .sort((a, b) => {
                        const ady = Math.abs(a.r.y - label.r.y);
                        const bdy = Math.abs(b.r.y - label.r.y);
                        return ady - bdy || a.r.x - b.r.x;
                      });
                    const input = inputs[0]?.el;
                    if (!input) return false;
                    return setValue(input);
                }""",
                {"labelText": label_text, "value": value},
            )
        )

    def click_order_row_action(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_texts: list[str],
    ) -> bool:
        return bool(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle, actionTexts }) => {
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text.includes(sourceOrderNo) || item.text.includes(orderTitle));
                    const row = rows[0]?.row;
                    if (!row) return false;
                    const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span, div'))
                      .map(el => ({
                        el,
                        text: (el.innerText || el.textContent || '').trim(),
                        r: el.getBoundingClientRect(),
                        tag: el.tagName.toLowerCase(),
                        role: el.getAttribute('role') || '',
                        cls: String(el.className || '')
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text)
                      .sort((a, b) => {
                        const score = item => {
                          if (item.tag === 'button') return 0;
                          if (item.tag === 'a') return 1;
                          if (item.role === 'button' || item.cls.includes('btn') || item.cls.includes('link')) return 2;
                          return 3;
                        };
                        return score(a) - score(b) || itemTextLength(a) - itemTextLength(b) || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                      });
                    function itemTextLength(item) { return item.text.replace(/\\s+/g, '').length; }
                    for (const actionText of actionTexts) {
                      const target = nodes.find(item => item.text === actionText) ||
                        nodes.find(item => item.text.includes(actionText));
                      if (target) {
                        const clickable = target.el.closest('button, a, [role="button"], .ant-btn') || target.el;
                        const r = clickable.getBoundingClientRect();
                        clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                        clickable.click();
                        return true;
                      }
                    }
                    return false;
                }""",
                {
                    "sourceOrderNo": source_order_no,
                    "orderTitle": order_title,
                    "actionTexts": action_texts,
                },
            )
        )

    def click_visible_action_text(self, frame: Frame, action_texts: list[str]) -> bool:
        return bool(
            frame.evaluate(
                """actionTexts => {
                    const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], li, span, div'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                    for (const actionText of actionTexts) {
                      const target = nodes.find(item => item.text === actionText) ||
                        nodes.find(item => item.text.includes(actionText));
                      if (target) {
                        target.el.click();
                        return true;
                      }
                    }
                    return false;
                }""",
                action_texts,
            )
        )

    def click_dropdown_option_anywhere(self, frame: Frame, option_text: str) -> bool:
        script = """optionText => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const norm = value => String(value || '').replace(/\\s+/g, '');
            const expected = norm(optionText);
            const nodes = Array.from(document.querySelectorAll(
              '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled), ' +
              '.ant-select-item-option:not(.ant-select-item-option-disabled), li, [role="option"], span, div'
            ))
              .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), raw: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
              .filter(item => visible(item.el))
              .filter(item => item.text === expected || item.text.includes(expected))
              .sort((a, b) => {
                const aExact = a.text === expected ? 0 : 1;
                const bExact = b.text === expected ? 0 : 1;
                return aExact - bExact || a.raw.length - b.raw.length || (a.r.width * a.r.height) - (b.r.width * b.r.height);
              });
            const target = nodes[0];
            if (!target) return false;
            target.el.scrollIntoView({ block: 'center', inline: 'center' });
            const r = target.el.getBoundingClientRect();
            const options = { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
            target.el.dispatchEvent(new MouseEvent('mouseover', options));
            target.el.dispatchEvent(new MouseEvent('mousedown', options));
            target.el.dispatchEvent(new MouseEvent('mouseup', options));
            target.el.click();
            return true;
        }"""
        clicked = bool(frame.evaluate(script, option_text))
        if not clicked:
            clicked = bool(self.page.evaluate(script, option_text))
        self.page.wait_for_timeout(800)
        return clicked

    def approve_workshop_revoke_request(
        self,
        *,
        main_order_no: str,
        source_order_no: str,
        order_title: str,
        remark: str,
        approve_amount: str = "1",
    ) -> str:
        amount_value = str(approve_amount).strip()
        assert amount_value.isdigit(), f"撤销审核结算金额必须填写整数数字；当前值：{approve_amount}"
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待同意撤销的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        action_texts = ["同意撤销", "处理撤销", "撤销审核", "撤销处理", "去处理", "审核", "处理", "同意"]
        clicked = self.click_order_row_action(frame, source_order_no, order_title, action_texts)
        if not clicked:
            opened = self.open_main_order_detail_from_list(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            assert opened, (
                f"丸掌柜订单管理未能打开待同意撤销订单详情；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; text={before_text[:1500]}"
            )
            clicked = self.click_sub_order_list_action_by_status(frame, ["撤销中", "异常中", "服务中"], action_texts)
            if not clicked:
                clicked = self.click_wanzhanggui_detail_action_text(
                    frame,
                    ["同意撤销", "处理撤销", "撤销审核", "撤销处理", "去处理", "审核"],
                )
        assert clicked, (
            f"丸掌柜未找到同意撤销入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; list_text={before_text[:1500]}; "
            f"detail_text={(self.visible_modal_text(frame) or frame.locator('body').inner_text(timeout=5000))[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        if modal_text:
            self.click_modal_radio_text(frame, "同意")
            self.fill_revoke_audit_modal(frame, remark=remark, amount=amount_value)
            self.submit_current_modal(frame, ["同意撤销", "确认同意", "提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        post_submit_text = ""
        try:
            post_submit_text += frame.locator("body").inner_text(timeout=5000)
        except Exception:
            pass
        try:
            post_submit_text += "\n" + self.page.locator("body").inner_text(timeout=5000)
        except Exception:
            pass
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        return f"{post_submit_text}\n{final_text}"

    def reject_workshop_revoke_request(
        self,
        *,
        main_order_no: str,
        source_order_no: str,
        order_title: str,
        remark: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待拒绝撤销的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        opened = self.open_main_order_detail_from_list(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        assert opened, (
            f"丸掌柜订单管理未能打开待拒绝撤销订单详情；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        detail_text = self.visible_modal_text(frame)
        if "撤销中" not in detail_text and ("异常中" in detail_text or "发单方取消撤销" in detail_text):
            return detail_text
        clicked = self.click_sub_order_list_action_by_status(frame, ["撤销中"], ["处理撤销"])
        assert clicked, (
            f"丸掌柜订单详情最新撤销中子单未找到处理撤销入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; detail={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert "撤销审核" in modal_text or "拒绝撤销" in modal_text, (
            f"点击处理撤销后未进入撤销审核弹框；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; modal={modal_text[:1500]}"
        )
        assert self.click_any_modal_radio_text(frame, ["拒绝撤销", "拒绝", "驳回", "不同意", "取消撤销"]), (
            f"撤销审核弹框未找到拒绝/取消撤销选项；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; modal={modal_text[:1500]}"
        )
        self.fill_revoke_audit_reject_modal(frame, remark=remark)
        self.submit_current_modal(frame, ["确认", "确定", "提交", "拒绝撤销", "确认拒绝", "保 存", "保存"])
        self.page.wait_for_timeout(3500)
        post_submit_text = ""
        try:
            post_submit_text += frame.locator("body").inner_text(timeout=5000)
        except Exception:
            pass
        try:
            post_submit_text += "\n" + self.page.locator("body").inner_text(timeout=5000)
        except Exception:
            pass
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        final_text = frame.locator("body").inner_text(timeout=5000)
        return f"{post_submit_text}\n{final_text}"

    def fill_revoke_audit_reject_modal(self, frame: Frame, *, remark: str) -> None:
        result = frame.evaluate(
            """remark => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return { filled: false, text: '' };
                const controls = Array.from(root.querySelectorAll('textarea, input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({
                    el,
                    placeholder: el.getAttribute('placeholder') || '',
                    text: (el.closest('.ant-form-item')?.innerText || el.parentElement?.innerText || ''),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = controls.find(item =>
                  item.el.tagName === 'TEXTAREA' ||
                  /审核意见|拒绝原因|原因|意见|备注|说明/.test(item.placeholder) ||
                  /审核意见|拒绝原因|原因|意见|备注|说明/.test(item.text)
                );
                if (!target) return { filled: false, text: root.innerText || root.textContent || '' };
                const proto = target.el.tagName === 'TEXTAREA'
                  ? window.HTMLTextAreaElement.prototype
                  : window.HTMLInputElement.prototype;
                const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                setter.call(target.el, '');
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' }));
                setter.call(target.el, remark);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: remark }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                return { filled: true, text: root.innerText || root.textContent || '' };
            }""",
            remark,
        )
        self.page.wait_for_timeout(500)
        assert result.get("filled"), f"撤销审核弹框未找到审核意见/拒绝原因输入框；modal={result.get('text', '')[:1500]}"

    def click_any_modal_radio_text(self, frame: Frame, texts: list[str]) -> bool:
        for text in texts:
            if self.click_modal_radio_text(frame, text):
                return True
        return False

    def fill_revoke_audit_modal(self, frame: Frame, *, remark: str, amount: str) -> None:
        result = frame.evaluate(
            """({ remark, amount }) => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return { amountFilled: false, remarkFilled: false, text: '' };
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  setter.call(el, '');
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' }));
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                const controls = Array.from(root.querySelectorAll('input, textarea'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({
                    el,
                    placeholder: el.getAttribute('placeholder') || '',
                    type: el.getAttribute('type') || '',
                    text: (el.closest('.ant-form-item')?.innerText || el.parentElement?.innerText || ''),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const amountControl = controls.find(item =>
                  item.type === 'number' ||
                  /结算金额|金额|撤销金额/.test(item.placeholder) ||
                  /结算金额|金额|撤销金额/.test(item.text)
                );
                if (amountControl) {
                  setValue(amountControl.el, amount);
                }
                const remarkControl = controls.find(item =>
                  item.el.tagName === 'TEXTAREA' ||
                  /审核意见|意见|备注|说明/.test(item.placeholder) ||
                  /审核意见|意见|备注|说明/.test(item.text)
                );
                if (remarkControl) {
                  setValue(remarkControl.el, remark);
                }
                return {
                  amountFilled: Boolean(amountControl),
                  remarkFilled: Boolean(remarkControl),
                  text: root.innerText || root.textContent || ''
                };
            }""",
            {"remark": remark, "amount": amount},
        )
        self.page.wait_for_timeout(500)
        assert result.get("amountFilled"), f"撤销审核弹框未找到结算金额输入框；modal={result.get('text', '')[:1500]}"

    def confirm_open_dialog(self, frame: Frame) -> None:
        clicked = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = buttons.find(item =>
                  ['立即接单', '确认接单', '接单', '确认发单', '继续发单', '确认', '确定', '是']
                    .some(text => item.text === text || item.text.includes(text))
                );
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        if not clicked:
            self.page.keyboard.press("Enter")


class WanyoujiFulfillmentPage(TenantAuthPage):
    def __init__(self, page: Page, tenant_home_url: str, bug_reporter: BugReporter | None = None):
        super().__init__(page, tenant_home_url, bug_reporter)
        self.image_captcha_solver = ImageCaptchaSolver(mode="ddddocr")

    def table_row_text_by_keywords(self, frame: Frame, keywords: list[str]) -> str:
        required = [keyword for keyword in keywords if keyword]
        if not required:
            return ""
        return str(
            frame.evaluate(
                """keywords => {
                    const matchesKeyword = (text, keyword) => {
                      if (!keyword) return true;
                      if (text.includes(keyword)) return true;
                      return keyword.length > 13 && text.includes(keyword.slice(0, 13));
                    };
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                      .map(row => ({ text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => keywords.every(keyword => matchesKeyword(item.text, keyword)));
                    return rows[0]?.text || '';
                }""",
                required,
            )
        )

    def table_row_amounts_by_keywords(self, frame: Frame, keywords: list[str]) -> list[str]:
        required = [keyword for keyword in keywords if keyword]
        if not required:
            return []
        values = frame.evaluate(
            """keywords => {
                const matchesKeyword = (text, keyword) => {
                  if (!keyword) return true;
                  if (text.includes(keyword)) return true;
                  return keyword.length > 13 && text.includes(keyword.slice(0, 13));
                };
                const rows = Array.from(document.querySelectorAll('tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.every(keyword => matchesKeyword(item.text, keyword)));
                const row = rows[0]?.row;
                if (!row) return [];
                const cells = Array.from(row.querySelectorAll('td, .ant-table-cell'))
                  .map(cell => (cell.innerText || cell.textContent || '').trim())
                  .filter(Boolean);
                const amounts = [];
                for (const cell of cells) {
                  const matches = cell.match(/[¥￥\\u00a5]\\s*\\d+(?:\\.\\d{1,2})?/g) || [];
                  amounts.push(...matches);
                }
                return amounts;
            }""",
            required,
        )
        if not isinstance(values, list):
            return []
        normalized: list[str] = []
        for value in values:
            digits = str(value).replace("¥", "").replace("￥", "").replace("\u00a5", "").strip()
            try:
                normalized.append(f"{float(digits):.2f}")
            except ValueError:
                continue
        return normalized

    def close_visible_modal_if_present(self, frame: Frame) -> None:
        closed = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const close = root.querySelector('.ant-modal-close, .ant-drawer-close, button[aria-label="Close"]');
                if (!close) return false;
                close.click();
                return true;
            }"""
        )
        if not closed:
            self.page.keyboard.press("Escape")
        self.page.wait_for_timeout(500)

    @staticmethod
    def amount_pair_in_text(text: str, expected_apply_amount: str, expected_penalty_amount: str) -> bool:
        currency_values = re.findall(r"(?:¥|￥|\u00a5)\s*(\d+(?:\.\d{1,2})?)", text)
        raw_values = currency_values or re.findall(r"\d+(?:\.\d{1,2})?", text)
        normalized = []
        for value in raw_values:
            digits = value.replace("¥", "").replace("￥", "")
            try:
                normalized.append(f"{float(digits):.2f}")
            except ValueError:
                continue
        expected = [f"{float(expected_apply_amount):.2f}", f"{float(expected_penalty_amount):.2f}"]
        for index in range(0, max(0, len(normalized) - 1)):
            if normalized[index : index + 2] == expected:
                return True
        return False

    def open_order_hall(self) -> list[Frame]:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        for attempt in range(3):
            try:
                self.click_sidebar_text("工作室履约应用", right_edge=True)
                try:
                    self.click_sidebar_path(
                        "/orderHall",
                        app_hint="Saas-丸友集",
                        fallback_texts=["抢单大厅", "外部合作抢单大厅"],
                    )
                except AssertionError:
                    self.click_sidebar_text("抢单大厅")
                break
            except AssertionError:
                if attempt == 2:
                    self.force_wanyouji_business_frame_url(WANYOUJI_ORDER_HALL_URL)
                    break
                self.page.wait_for_timeout(2000)
                self.goto(WANYOUJI_CONSOLE_URL)
                self.wait_for_enterprise_console_shell()
        self.page.wait_for_timeout(4000)
        frames: list[Frame] = []
        for _ in range(8):
            frames = [
                frame
                for frame in self.page.frames
                if ("test-wshop" in frame.url or "test-orbit" in frame.url)
                and self.frame_contains_any(frame, ["抢单大厅", "立即接单", "来源单号", "公池单", "私单"])
            ]
            if frames:
                return frames
            if _ in {2, 5}:
                self.force_wanyouji_business_frame_url(WANYOUJI_ORDER_HALL_URL)
                self.page.wait_for_timeout(2500)
            self.page.wait_for_timeout(1000)
        urls = [frame.url for frame in self.page.frames]
        texts: list[str] = []
        for frame in self.page.frames:
            if "test-wshop" in frame.url or "test-orbit" in frame.url:
                try:
                    texts.append(f"{frame.url}: {frame.locator('body').inner_text(timeout=2000)[:800]}")
                except Exception:
                    pass
        raise AssertionError(f"未找到丸友集抢单大厅 iframe；frames={urls}; texts={texts}")
    def verify_order_reached_hall(
        self,
        source_order_no: str,
        order_title: str,
        expected_scope: str,
        *,
        main_order_no: str = "",
        expected_status: str | list[str] | None = None,
    ) -> str:
        frames = self.open_order_hall()
        found_texts: list[str] = []
        search_keywords = [item for item in [main_order_no, source_order_no, order_title] if item]
        expected_statuses = (
            [expected_status]
            if isinstance(expected_status, str)
            else list(expected_status or [])
        )
        for scope_text in self.scope_candidates(expected_scope):
            for frame in frames:
                if scope_text == "私单" and not self.frame_has_order_hall_tabs(frame):
                    continue
                self.click_order_hall_tab(frame, scope_text)
                self.page.wait_for_timeout(1000)
                for keyword in search_keywords:
                    self.search_order_by_best_identity(
                        frame,
                        main_order_no=keyword if keyword.startswith("BO") else "",
                        source_order_no=keyword if keyword == source_order_no else "",
                        order_title=keyword if keyword == order_title else "",
                    )
                    text = frame.locator("body").inner_text(timeout=5000)
                    row_text = self.order_hall_row_text(
                        frame,
                        [main_order_no, source_order_no, order_title],
                    )
                    target_text = row_text or text
                    found_texts.append(f"[{scope_text}/{keyword}] {target_text[:1000]}")
                    if any(item and item in text for item in [main_order_no, source_order_no, order_title]):
                        if expected_statuses:
                            assert any(status in target_text for status in expected_statuses), (
                                f"丸友集抢单大厅订单状态不符合预期；expected_status={expected_statuses}; "
                                f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
                                f"order_title={order_title}; target_text={target_text[:1500]}"
                            )
                        return target_text
        if (
            not expected_statuses
            and expected_scope == "私单"
            and self.verify_order_in_workshop_order_list(source_order_no, order_title)
        ):
            return ""
        raise AssertionError(
            f"Wanyouji order hall did not show order; expected_scope={expected_scope}; "
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
            f"order_title={order_title}; summaries={found_texts}"
        )

    def order_hall_row_text(self, frame: Frame, keywords: list[str]) -> str:
        return str(
            frame.evaluate(
                """keywords => {
                    const validKeywords = keywords.filter(Boolean);
                    if (!validKeywords.length) return '';
                    const rows = Array.from(document.querySelectorAll(
                      '.ant-table-row, tr, [class*="table-row"], [class*="list-item"], [class*="card"]'
                    ))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && item.text);
                    const exact = rows.find(item => validKeywords.some(keyword => item.text.includes(keyword)));
                    if (exact) return exact.text;
                    const likely = rows.find(item =>
                      /待接单|立即接单|抢单|私单|公池单/.test(item.text) &&
                      validKeywords.some(keyword => keyword.startsWith('BO') && item.text.includes(keyword.slice(0, 10)))
                    );
                    return likely?.text || '';
                }""",
                keywords,
            )
        )

    def accept_external_order_from_hall(self, source_order_no: str, order_title: str, expected_scope: str) -> str:
        if self.verify_order_in_workshop_order_list(source_order_no, order_title):
            return self.scope_candidates(expected_scope)[0]
        summaries: list[str] = []
        for scope_text in self.scope_candidates(expected_scope):
            frames = self.open_order_hall()
            for frame in frames:
                if scope_text == "私单" and not self.frame_has_order_hall_tabs(frame):
                    continue
                self.click_order_hall_tab(frame, scope_text)
                self.search_order_by_best_identity(frame, source_order_no=source_order_no, order_title=order_title)
                try:
                    text = frame.locator("body").inner_text(timeout=5000)
                    row_text = self.order_hall_row_text(frame, [source_order_no, order_title])
                except Exception:
                    frame = self.reopen_order_hall_frame(scope_text, source_order_no)
                    self.search_order_by_best_identity(frame, source_order_no=source_order_no, order_title=order_title)
                    text = frame.locator("body").inner_text(timeout=5000)
                    row_text = self.order_hall_row_text(frame, [source_order_no, order_title])
                summaries.append(f"[{scope_text}] {(row_text or text)[:1000]}")
                if source_order_no not in text and order_title not in text:
                    continue
                after_accept_text = ""
                for attempt in range(5):
                    clicked = self.click_row_action(
                        frame,
                        source_order_no,
                        order_title,
                        ["立即接单", "接单", "确认接单", "抢单"],
                    )
                    assert clicked, (
                        f"找到外部订单但未找到立即接单按钮；scope={scope_text}; "
                        f"source_order_no={source_order_no}; text={text[:1500]}"
                    )
                    self.page.wait_for_timeout(1000)
                    self.confirm_grab_order_dialog(frame)
                    self.page.wait_for_timeout(3000)
                    after_accept_text = self.visible_page_and_frame_text(frame)
                    if self.order_still_waiting_to_accept(frame, source_order_no, order_title):
                        self.refresh_order_hall_captcha_attempt(scope_text, attempt)
                        continue
                    if self.verify_order_in_workshop_order_list(source_order_no, order_title):
                        return scope_text
                    else:
                        assert self.verify_order_in_workshop_order_list(source_order_no, order_title), (
                            f"外部订单接单后状态已变化但未在订单列表查到；"
                            f"source_order_no={source_order_no}; order_title={order_title}; "
                            f"after_accept_text={after_accept_text[:2000]}"
                        )
                raise AssertionError(
                    f"外部订单多次尝试接单后仍未成功流转到订单列表，疑似图形验证码未通过；"
                    f"source_order_no={source_order_no}; order_title={order_title}; "
                    f"after_accept_text={after_accept_text[:2000]}"
                )
        if self.verify_order_in_workshop_order_list(source_order_no, order_title):
            return self.scope_candidates(expected_scope)[0]
        raise AssertionError(
            f"未在丸友集工作室履约应用-抢单大厅找到可接外部订单；"
            f"source_order_no={source_order_no}; order_title={order_title}; summaries={summaries}"
        )

    def reject_external_order_from_hall(
        self,
        *,
        source_order_no: str,
        order_title: str,
        expected_scope: str,
        main_order_no: str = "",
        reject_reason: str = "",
        manual_reject: bool = False,
    ) -> str:
        frames = self.open_order_hall()
        summaries: list[str] = []
        for scope_text in self.scope_candidates(expected_scope):
            for frame in frames:
                if scope_text == "私单" and not self.frame_has_order_hall_tabs(frame):
                    continue
                self.click_order_hall_tab(frame, scope_text)
                self.page.wait_for_timeout(1000)
                self.search_order_by_labels(frame, source_order_no, ["来源单号"])
                before_text = frame.locator("body").inner_text(timeout=5000)
                target_text = self.order_hall_row_text(frame, [source_order_no])
                summaries.append(f"[{scope_text}/{source_order_no}] {target_text or before_text[:1000]}")
                if source_order_no not in before_text:
                    continue
                assert source_order_no in target_text, (
                    f"丸友集抢单大厅查询后未定位到目标订单行；source_order_no={source_order_no}; "
                    f"text={before_text[:1500]}"
                )
                assert "待接单" in target_text, (
                    f"S-FLOW-013 前置状态不是丸友集待接单；source_order_no={source_order_no}; "
                    f"target_text={target_text[:1500]}"
                )

                self.click_reject_order_button_in_row(frame, source_order_no)
                self.page.wait_for_timeout(1000)
                reason = reject_reason or f"S-FLOW-013 拒绝上游订单 {main_order_no or source_order_no}"
                if manual_reject:
                    modal_text = self.wait_reject_order_modal(
                        frame,
                        diagnostics=f"row={target_text[:1000]}",
                    )
                    print(
                        "[E2E] S-FLOW-013 已打开丸友集拒绝理由弹框，请手动输入拒绝理由并点击确认；"
                        f"main_order_no={main_order_no}; source_order_no={source_order_no}"
                    )
                    self.wait_manual_reject_order_submitted(frame, modal_text)
                else:
                    self.submit_reject_order_modal(frame, reason)
                self.page.wait_for_timeout(3000)
                self.search_order_by_labels(frame, source_order_no, ["来源单号"])
                final_text = self.visible_page_and_frame_text(frame)
                final_row_text = self.order_hall_row_text(frame, [source_order_no])
                final_target_text = final_row_text or final_text
                assert source_order_no not in final_text, (
                    f"丸友集拒绝后抢单大厅仍可查询到目标订单；source_order_no={source_order_no}; "
                    f"final={final_target_text[:1500]}"
                )
                return final_target_text
        raise AssertionError(
            f"未在丸友集抢单大厅找到待拒绝订单；expected_scope={expected_scope}; "
            f"main_order_no={main_order_no}; source_order_no={source_order_no}; "
            f"order_title={order_title}; summaries={summaries}"
        )

    def click_reject_order_button_in_row(self, frame: Frame, source_order_no: str) -> None:
        row = frame.locator("tr.ant-table-row, tr, [class*='table-row'], [class*='list-item'], [class*='card']").filter(
            has_text=source_order_no
        ).first
        assert row.count(), f"丸友集抢单大厅未找到目标订单行：{source_order_no}"

        button = row.get_by_role("button", name="拒绝接单").last
        if button.count() == 0:
            button = row.locator("button").filter(has_text="拒绝接单").last
        if button.count() == 0:
            button = row.locator("button.ant-btn-link").last
        assert button.count(), f"目标订单行内未找到【拒绝接单】按钮：{source_order_no}"

        button.scroll_into_view_if_needed(timeout=3000)
        button.click(force=True, timeout=5000)
        self.page.wait_for_timeout(1000)

    def wait_reject_order_modal(self, frame: Frame, diagnostics: str = "") -> str:
        last_text = ""
        for _ in range(10):
            last_text = self.reject_order_modal_text(frame)
            compact = re.sub(r"\s+", "", last_text)
            if any(keyword in compact for keyword in ["拒绝", "拒单", "不接单", "原因", "理由"]):
                return last_text
            self.page.wait_for_timeout(500)
        raise AssertionError(f"点击拒绝后未出现拒绝理由弹框；diagnostics={diagnostics}; modal={last_text[:1000]}")

    def wait_manual_reject_order_submitted(self, frame: Frame, modal_text: str) -> None:
        timeout_seconds = int(os.getenv("E2E_MANUAL_REJECT_TIMEOUT_SECONDS", "300"))
        for _ in range(max(1, timeout_seconds * 2)):
            if not self.reject_order_modal_visible(frame):
                self.page.wait_for_timeout(1500)
                return
            self.page.wait_for_timeout(500)
        raise AssertionError(
            f"等待手动填写并提交拒绝理由超时；timeout_seconds={timeout_seconds}; modal={modal_text[:1500]}"
        )

    def reject_order_modal_visible(self, frame: Frame) -> bool:
        text = self.reject_order_modal_text(frame)
        compact = re.sub(r"\s+", "", text)
        return any(keyword in compact for keyword in ["拒绝", "拒单", "不接单", "原因", "理由"])

    def reject_order_modal_text(self, frame: Frame) -> str:
        script = """() => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const modalText = root => {
              const ownText = root.innerText || root.textContent || '';
              const fieldText = Array.from(root.querySelectorAll('input, textarea'))
                .map(el => [el.getAttribute('placeholder') || '', el.value || ''].join(' '))
                .join(' ');
              const buttonText = Array.from(root.querySelectorAll('button'))
                .map(el => el.innerText || el.textContent || '')
                .join(' ');
              return [ownText, fieldText, buttonText].join('\\n');
            };
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
              .filter(visible)
              .filter(el => /拒绝接单|拒绝理由|请输入拒绝理由|确认拒绝/.test(modalText(el)));
            const root = roots.pop();
            return root ? modalText(root) : '';
        }"""
        texts: list[str] = []
        for scope in [frame, self.page]:
            try:
                text = str(scope.evaluate(script))
            except Exception:
                text = ""
            if text:
                texts.append(text)
        return "\n".join(texts)

    def click_reject_order_button_by_locator(self, frame: Frame) -> bool:
        for text in ["拒绝接单", "拒绝订单", "拒绝", "不接单"]:
            locator = frame.get_by_text(text, exact=True).last
            try:
                if locator.count() == 0:
                    continue
                locator.scroll_into_view_if_needed(timeout=2000)
                locator.click(force=True, timeout=3000)
                self.page.wait_for_timeout(1000)
                if self.reject_order_modal_visible(frame):
                    return True
            except Exception:
                continue
        return self.reject_order_modal_visible(frame)

    def click_reject_order_button_by_dom(self, frame: Frame, keywords: list[str]) -> bool:
        clicked = frame.evaluate(
            """keywords => {
                const validKeywords = keywords.filter(Boolean);
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll(
                  'tr, .ant-table-row, [class*="table"] [class*="row"], [class*="order"], [class*="card"], taro-view-core'
                ))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => validKeywords.some(keyword => item.text.includes(keyword)))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const row = rows[0]?.row || document;
                const actionTexts = ['拒绝接单', '拒绝订单', '拒绝', '不接单'];
                const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span, div, taro-view-core, taro-text-core'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => visible(item.el) && item.text)
                  .filter(item => actionTexts.some(action => item.text === action || item.text.includes(action)))
                  .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const targetNode = nodes[0]?.el;
                if (!targetNode) return false;
                let clickable = targetNode;
                for (let el = targetNode; el && el !== row.parentElement; el = el.parentElement) {
                  const text = (el.innerText || el.textContent || '').trim();
                  const r = el.getBoundingClientRect();
                  const style = window.getComputedStyle(el);
                  const hasHandler = !!(el.onclick || el.getAttribute('role') === 'button' || style.cursor === 'pointer');
                  const looksAction = actionTexts.some(action => text === action || text.includes(action));
                  if (r.width > 0 && r.height > 0 && looksAction && (hasHandler || ['BUTTON', 'A'].includes(el.tagName) || String(el.tagName).includes('TARO'))) {
                    clickable = el;
                    break;
                  }
                }
                const r = clickable.getBoundingClientRect();
                clickable.scrollIntoView({ block: 'center', inline: 'center' });
                const x = r.x + r.width / 2;
                const y = r.y + r.height / 2;
                for (const type of ['mouseover', 'mouseenter', 'mousedown', 'mouseup', 'click']) {
                  clickable.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }));
                }
                if (typeof clickable.click === 'function') clickable.click();
                return true;
            }""",
            keywords,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked) and self.reject_order_modal_visible(frame)

    def fill_reject_order_reason(self, frame: Frame, reason: str) -> None:
        written = frame.evaluate(
            """reason => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return { ok: false, value: '', text: '' };
                const controls = Array.from(root.querySelectorAll('textarea, input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = controls.find(item => /原因|理由|备注|说明/.test(item.placeholder)) || controls[0];
                if (!target) return { ok: false, value: '', text: root.innerText || root.textContent || '' };
                const proto = target.el.tagName === 'TEXTAREA'
                  ? window.HTMLTextAreaElement.prototype
                  : window.HTMLInputElement.prototype;
                const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                target.el.focus();
                setter.call(target.el, '');
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' }));
                setter.call(target.el, reason);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: reason }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                return { ok: String(target.el.value || '') === reason, value: String(target.el.value || ''), text: root.innerText || root.textContent || '' };
            }""",
            reason,
        )
        assert written.get("ok"), (
            f"拒绝理由未正确写入弹框；expected={reason}; actual={written.get('value')}; "
            f"modal={str(written.get('text', ''))[:1000]}"
        )
        self.page.wait_for_timeout(500)

    def submit_reject_order_modal(self, frame: Frame, reason: str) -> str:
        last_error = ""
        for scope in [frame, self.page]:
            try:
                modal = scope.locator(".ant-modal:visible").last
                control = scope.locator("textarea[placeholder='请输入拒绝理由（必填）']:visible").last
                if control.count() == 0:
                    control = scope.locator("textarea[placeholder*='拒绝理由']:visible").last
                if control.count() == 0:
                    control = scope.locator("input[placeholder*='拒绝理由']:visible").last
                if control.count() == 0:
                    control = scope.locator(".ant-modal:visible textarea:visible, .ant-modal:visible input:visible").last

                modal_text = modal.inner_text(timeout=3000) if modal.count() else self.reject_order_modal_text(frame)
                if control.count():
                    control.click(force=True)
                    control.fill("")
                    control.type(reason, delay=30)
                    actual = control.input_value(timeout=3000)
                    assert actual == reason, f"拒绝理由未正确输入；expected={reason}; actual={actual}"
                    owner_modal = control.locator("xpath=ancestor::*[contains(@class, 'ant-modal')][1]")
                    if owner_modal.count():
                        modal = owner_modal
                        modal_text = modal.inner_text(timeout=3000)

                confirm_button = modal.get_by_role("button", name="确认拒绝").last if modal.count() else scope.get_by_role("button", name="确认拒绝").last
                if confirm_button.count() == 0 and modal.count():
                    confirm_button = modal.locator("button:visible").filter(has_text="确认拒绝").last
                if confirm_button.count() == 0:
                    confirm_button = scope.locator("button:visible").filter(has_text="确认拒绝").last
                if confirm_button.count():
                    confirm_button.click(force=True)
                elif not self.click_reject_confirm_text(scope):
                    last_error = f"confirm button not found; modal={modal_text[:1000]}"
                    continue
                self.page.wait_for_timeout(1500)
                return modal_text
            except Exception as exc:
                last_error = str(exc)
        self.snapshot_reject_order_modal_failure(frame, reason, last_error)
        raise AssertionError(
            f"拒绝接单弹框提交失败；reason={reason}; error={last_error}; "
            f"modal={self.reject_order_modal_text(frame)[:1000]}"
        )

    def snapshot_reject_order_modal_failure(self, frame: Frame, reason: str, error: str) -> None:
        out_dir = Path("runtime/probes/s_flow_013_reject_failure")
        out_dir.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        try:
            self.page.screenshot(path=out_dir / f"{timestamp}-page.png", full_page=True)
        except Exception:
            pass
        try:
            frame.locator("body").screenshot(path=out_dir / f"{timestamp}-frame.png", timeout=5000)
        except Exception:
            pass
        payload = {
            "reason": reason,
            "error": error,
            "page_text": "",
            "frame_url": frame.url,
            "frames": [item.url for item in self.page.frames],
            "frame_controls": [],
            "page_controls": [],
        }
        for key, scope in [("frame_controls", frame), ("page_controls", self.page)]:
            try:
                payload[key] = scope.evaluate(
                    """() => Array.from(document.querySelectorAll('button, input, textarea, .ant-modal, .ant-drawer, .ant-popover'))
                      .map((el, index) => {
                        const r = el.getBoundingClientRect();
                        return {
                          index,
                          tag: el.tagName,
                          cls: String(el.className || ''),
                          text: (el.innerText || el.textContent || '').trim(),
                          placeholder: el.getAttribute('placeholder') || '',
                          value: el.value || '',
                          visible: r.width > 0 && r.height > 0,
                          rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }
                        };
                      })
                      .filter(item => item.visible)
                      .slice(0, 300)"""
                )
            except Exception as exc:
                payload[key] = [{"error": str(exc)}]
        try:
            payload["page_text"] = self.page.locator("body").inner_text(timeout=3000)[:3000]
        except Exception:
            pass
        (out_dir / f"{timestamp}.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")

    def click_reject_confirm_text(self, scope: Frame | Page) -> bool:
        clicked = scope.evaluate(
            """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], taro-view-core, taro-text-core, div, span'))
                  .map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                    disabled: Boolean(el.disabled)
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .filter(item => item.text === '确认拒绝' || item.text.includes('确认拒绝'))
                  .sort((a, b) => {
                    const aExact = a.text === '确认拒绝' ? 0 : 1;
                    const bExact = b.text === '确认拒绝' ? 0 : 1;
                    if (aExact !== bExact) return aExact - bExact;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"], taro-view-core') || target.el;
                clickable.scrollIntoView({ block: 'center', inline: 'center' });
                if (typeof clickable.click === 'function') clickable.click();
                return true;
            }"""
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def danger_row_action_candidates(self, frame: Frame, keywords: list[str]) -> list[str]:
        return list(
            frame.evaluate(
                """keywords => {
                    const validKeywords = keywords.filter(Boolean);
                    const rows = Array.from(document.querySelectorAll(
                      'tr, .ant-table-row, [class*="table"] [class*="row"], [class*="order"], [class*="card"]'
                    ))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => validKeywords.some(keyword => item.text.includes(keyword)))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                    const root = rows[0]?.row || document;
                    return Array.from(root.querySelectorAll('button, a, [role="button"], span'))
                      .map(el => {
                        const clickable = el.closest('button, a, [role="button"]') || el;
                        const text = (el.innerText || el.textContent || '').trim();
                        const clickableText = (clickable.innerText || clickable.textContent || '').trim();
                        const r = el.getBoundingClientRect();
                        return { text, clickableText, r, disabled: Boolean(clickable.disabled) };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .map(item => item.text || item.clickableText)
                      .filter(Boolean)
                      .slice(0, 80);
                }""",
                keywords,
            )
        )

    def click_danger_row_action(self, frame: Frame, keywords: list[str], action_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """({ keywords, actionTexts }) => {
                const validKeywords = keywords.filter(Boolean);
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll(
                  'tr, .ant-table-row, [class*="table"] [class*="row"], [class*="order"], [class*="card"]'
                ))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => validKeywords.some(keyword => item.text.includes(keyword)))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const roots = rows.map(item => item.row);
                if (!roots.length) roots.push(document);
                for (const root of roots) {
                  const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span, div, taro-view-core, taro-text-core'))
                    .map(el => {
                      const clickable = el.closest('button, a, [role="button"], taro-view-core') || el;
                      return {
                        el,
                        clickable,
                        text: (el.innerText || el.textContent || '').trim(),
                        clickableText: (clickable.innerText || clickable.textContent || '').trim(),
                        r: el.getBoundingClientRect(),
                        disabled: Boolean(clickable.disabled)
                      };
                    })
                    .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled && item.text);
                  for (const actionText of actionTexts) {
                    const target = nodes.find(item => item.text === actionText || item.clickableText === actionText) ||
                      nodes.find(item => item.text.includes(actionText) || item.clickableText.includes(actionText));
                    if (!target) continue;
                    target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                    target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            {"keywords": keywords, "actionTexts": action_texts},
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def dispatch_workshop_order_to_player_hall(
        self,
        source_order_no: str,
        order_title: str,
        assignment_method: str = "抢单大厅",
        player_name: str = "",
        player_phone: str = "",
    ) -> str:
        assert assignment_method in {"抢单大厅", "指派打手", "智能派单"}, (
            "E2E_TASK_ASSIGNMENT_METHOD 仅支持：抢单大厅、指派打手、智能派单；"
            f"当前值：{assignment_method}"
        )
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"工作室履约应用-订单列表未找到待发单订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        clicked = self.click_row_action(frame, source_order_no, order_title, ["立即发单", "继续发单", "再次发单", "发单"])
        assert clicked, (
            f"工作室履约应用-订单列表未找到立即发单/继续发单按钮；source_order_no={source_order_no}; text={text[:1500]}"
        )
        assert self.wait_for_dispatch_modal(frame), (
            f"点击立即发单后未打开发单弹框；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if self.click_modal_button(frame, ["继续发单"]):
            assert self.wait_for_dispatch_modal(frame), (
                f"点击继续发单后未进入确认发单弹框；source_order_no={source_order_no}; "
                f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
        if "请添加子单" not in self.visible_modal_text(frame):
            clicked_seed_confirm = self.click_modal_button(frame, ["确认发单", "确定发单"])
            assert clicked_seed_confirm, (
                f"发单表单未出现【请添加子单】，且未找到用于生成子单列表的确认发单按钮；"
                f"source_order_no={source_order_no}; modal={self.visible_modal_text(frame)[:1500]}"
            )
            assert self.wait_for_continue_dispatch_child_form(frame), (
                f"点击确认发单后未出现【请添加子单】列表；source_order_no={source_order_no}; "
                f"modal={self.visible_modal_text(frame)[:1500]}"
            )
        self.select_assignment_method(frame, assignment_method)
        if assignment_method == "指派打手":
            self.select_dispatch_player(frame, player_name=player_name, player_phone=player_phone)
        self.fill_dispatch_modal_defaults(frame, source_order_no, order_title)
        self.assert_workshop_dispatch_price_ready(frame)
        clicked_confirm = self.click_modal_button(frame, ["确认发单", "确定发单"])
        if not clicked_confirm:
            clicked_confirm = self.click_overlay_action(frame, ["确认发单", "确定发单"])
        assert clicked_confirm, f"发单弹框未找到确认发单按钮；modal={self.visible_modal_text(frame)[:1500]}"
        self.assert_no_dispatch_validation_error(frame, source_order_no)
        prompt_text = self.wait_for_continue_dispatch_confirm_prompt(frame)
        if prompt_text:
            if not self.dispatch_result_modal_has_pending_sub_order(prompt_text):
                clicked_final_confirm = self.click_modal_button(frame, ["确 认", "确认", "确 定", "确定"]) or self.click_overlay_action(
                    frame,
                    ["确 认", "确认", "确 定", "确定"],
                )
                assert clicked_final_confirm, (
                    f"指派打手发单提示弹窗未找到【确认】按钮；source_order_no={source_order_no}; "
                    f"modal={prompt_text[:1500]}"
                )
                self.assert_no_dispatch_validation_error(frame, source_order_no)
        self.page.wait_for_timeout(4000)
        result_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        self.assert_no_dispatch_validation_error(frame, source_order_no)
        assigned_sub_order_no = self.extract_assigned_player_sub_order_no(result_text)
        assert assigned_sub_order_no, (
            f"指派打手发单后未提取到待接单打手子单号；source_order_no={source_order_no}; "
            f"modal={result_text[:1500]}"
        )
        return assigned_sub_order_no

    def dispatch_result_modal_has_pending_sub_order(self, text: str) -> bool:
        return bool(re.search(r"WY\d{20}", text or "")) and "待接单" in (text or "")

    def extract_assigned_player_sub_order_no(self, text: str) -> str:
        match = re.search(r"WY\d{20}", text or "")
        return match.group(0) if match else ""

    def continue_dispatch_cancelled_child_from_workshop_order_list(
        self,
        source_order_no: str,
        order_title: str,
        assignment_method: str = "抢单大厅",
        required_terminal_sub_order_no: str = "",
        allowed_terminal_statuses: list[str] | None = None,
    ) -> tuple[str, str, str]:
        assert assignment_method in {"抢单大厅", "指派打手", "智能派单"}, (
            "E2E_TASK_ASSIGNMENT_METHOD 仅支持：抢单大厅、指派打手、智能派单；"
            f"当前值：{assignment_method}"
        )
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待续发订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        clicked = (
            self.click_workshop_order_list_action(frame, source_order_no, "操作")
            or self.click_row_action_allow_revoke(frame, source_order_no, order_title, ["操作", "操 作"])
        )
        assert clicked, (
            f"S-FLOW-032 订单列表行未找到【操作】入口；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}"
        )
        detail_text = self.wait_for_continue_dispatch_cancelled_child_detail(frame, source_order_no, order_title)
        allowed_terminal_statuses = allowed_terminal_statuses or ["已取消", "取消订单", "已撤销", "撤销完成"]
        assert any(status in detail_text for status in allowed_terminal_statuses), (
            f"操作弹窗未展示允许续发的终态子单 {allowed_terminal_statuses}；source_order_no={source_order_no}; "
            f"modal={detail_text[:1500]}"
        )
        if required_terminal_sub_order_no:
            target_sub_row = self.extract_sub_order_row_from_modal_text(detail_text, required_terminal_sub_order_no)
            assert target_sub_row and any(status in target_sub_row for status in allowed_terminal_statuses), (
                f"目标子单尚未终态，不允许继续发单；source_order_no={source_order_no}; "
                f"sub_order_no={required_terminal_sub_order_no}; row={target_sub_row[:1500]}; modal={detail_text[:1500]}"
            )

        clicked_continue = self.click_modal_button(frame, ["继续发单", "再次发单"])
        if not clicked_continue:
            clicked_continue = self.click_overlay_action(frame, ["继续发单", "再次发单"])
        assert clicked_continue, (
            f"S-FLOW-032 子单列表弹窗未找到【继续发单】按钮；source_order_no={source_order_no}; "
            f"modal={detail_text[:1500]}"
        )
        form_text = self.wait_for_continue_dispatch_child_form(frame)
        assert form_text, (
            f"S-FLOW-032 点击继续发单后未进入【请添加子单】表单；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )

        self.select_assignment_method(frame, assignment_method)
        self.fill_dispatch_modal_defaults(frame, source_order_no, order_title)
        self.assert_workshop_dispatch_price_ready(frame)
        clicked_confirm = self.click_modal_button(frame, ["确认发单", "确定发单"])
        if not clicked_confirm:
            clicked_confirm = self.click_overlay_action(frame, ["确认发单", "确定发单"])
        assert clicked_confirm, (
            f"S-FLOW-032 请添加子单表单未找到【确认发单】按钮；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.assert_no_dispatch_validation_error(frame, source_order_no)
        prompt_text = self.wait_for_continue_dispatch_confirm_prompt(frame)
        assert prompt_text, (
            f"S-FLOW-032 点击【确认发单】后未出现新打手接单确认提示；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )
        clicked_final_confirm = self.click_modal_button(frame, ["确 认", "确认", "确 定", "确定"]) or self.click_overlay_action(
            frame,
            ["确 认", "确认", "确 定", "确定"],
        )
        assert clicked_final_confirm, (
            f"S-FLOW-032 新打手接单提示弹窗未找到【确认】按钮；source_order_no={source_order_no}; "
            f"modal={prompt_text[:1500]}"
        )
        self.page.wait_for_timeout(4000)
        after_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in after_text or order_title in after_text, (
            f"S-FLOW-032 续发后丸友集订单列表未找到订单；source_order_no={source_order_no}; "
            f"text={after_text[:1500]}"
        )
        return before_text, detail_text, after_text

    def revoke_in_progress_child_from_workshop_order_detail(
        self,
        source_order_no: str,
        order_title: str,
        reason: str,
        revoke_amount: str = "1",
    ) -> tuple[str, str, str]:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"S-FLOW-078 丸友集订单列表未找到代练中子单订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
        assert opened, (
            f"S-FLOW-078 未能打开丸友集订单详情；source_order_no={source_order_no}; "
            f"text={before_text[:1500]}"
        )
        self.scroll_sub_order_table_to_right(frame)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        target_sub_order_no = self.latest_sub_order_no_by_status(detail_text, ["代练中", "服务中"])
        assert target_sub_order_no, (
            f"S-FLOW-078 订单详情未找到代练中子单；source_order_no={source_order_no}; "
            f"detail={detail_text[:1500]}"
        )
        clicked = self.click_sub_order_row_action_by_no(
            frame,
            target_sub_order_no,
            ["撤销子单", "申请撤销", "撤销"],
        )
        if not clicked:
            clicked = self.click_sub_order_row_action_by_status(
                frame,
                ["代练中", "服务中"],
                ["撤销子单", "申请撤销", "撤销"],
            )
        assert clicked, (
            f"S-FLOW-078 目标代练中子单未找到【撤销】入口；source_order_no={source_order_no}; "
            f"sub_order_no={target_sub_order_no}; detail={detail_text[:1500]}"
        )
        modal_text = self.wait_for_child_revoke_form_text(frame)
        assert modal_text, (
            f"S-FLOW-078 点击撤销后未打开撤销子单弹框；source_order_no={source_order_no}; "
            f"sub_order_no={target_sub_order_no}; modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.fill_revoke_order_modal_fields(frame, reason=reason, amount=revoke_amount)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_overlay_action(
            frame,
            ["确认撤销", "确认申请", "提交申请", "确认提交", "提交", "确 认", "确认", "确 定", "确定"],
        )
        assert submitted, (
            f"S-FLOW-078 撤销子单弹框未找到提交按钮；source_order_no={source_order_no}; "
            f"modal={modal_text[:1500]}"
        )
        self.confirm_open_dialog(frame)
        final_detail_text = ""
        final_row = ""
        for _ in range(10):
            self.page.wait_for_timeout(2000)
            final_detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
            final_row = self.extract_sub_order_row_from_modal_text(final_detail_text, target_sub_order_no)
            if final_row and "撤销中" not in final_row and any(
                status in final_row for status in ["撤销完成", "已撤销", "撤销结束", "已取消", "取消订单"]
            ):
                break
            self.close_current_modal(frame)
            reopened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
            assert reopened, (
                f"S-FLOW-078 提交撤销后未能重新打开订单详情；source_order_no={source_order_no}; "
                f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
            self.scroll_sub_order_table_to_right(frame)
        assert final_row and "撤销中" not in final_row and any(
            status in final_row for status in ["撤销完成", "已撤销", "撤销结束", "已取消", "取消订单"]
        ), (
            f"S-FLOW-078 子单撤销后未进入终态；source_order_no={source_order_no}; "
            f"sub_order_no={target_sub_order_no}; row={final_row[:1500]}; detail={final_detail_text[:1500]}"
        )
        return target_sub_order_no, detail_text, final_detail_text

    def wait_for_child_revoke_form_text(self, frame: Frame, timeout_ms: int = 8000) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if last_text and any(title in last_text for title in ["撤销子单", "申请撤销", "撤销"]) and any(
                key in last_text for key in ["撤销金额", "结算金额", "撤销原因", "原因", "确认撤销", "提交"]
            ):
                return last_text
            self.page.wait_for_timeout(500)
        return ""

    @staticmethod
    def extract_sub_order_row_from_modal_text(modal_text: str, sub_order_no: str) -> str:
        if not sub_order_no:
            return ""
        index = (modal_text or "").find(sub_order_no)
        if index < 0:
            return ""
        next_index = len(modal_text)
        for marker in ["\nWY", "\r\nWY"]:
            found = modal_text.find(marker, index + len(sub_order_no))
            if found > index:
                next_index = min(next_index, found)
        return modal_text[index:next_index].strip()

    def wait_for_continue_dispatch_cancelled_child_detail(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        timeout_ms: int = 10000,
    ) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if (
                ("子单列表" in last_text or "主单" in last_text)
                and (source_order_no in last_text or order_title in last_text)
                and ("继续发单" in last_text or "再次发单" in last_text)
            ):
                return last_text
            self.page.wait_for_timeout(500)
        return last_text

    def wait_for_continue_dispatch_child_form(self, frame: Frame, timeout_ms: int = 10000) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if "请添加子单" in last_text and ("确认发单" in last_text or "发单价" in last_text):
                return last_text
            self.page.wait_for_timeout(500)
        return last_text

    def wait_for_continue_dispatch_confirm_prompt(self, frame: Frame, timeout_ms: int = 10000) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if (
                ("新打手" in last_text or "上一笔打手订单" in last_text or "提示" in last_text)
                and ("确认" in last_text or "确定" in last_text)
            ):
                return last_text
            self.page.wait_for_timeout(500)
        return last_text

    def withdraw_player_dispatch_from_workshop_order_list(
        self,
        source_order_no: str,
        order_title: str,
        reason: str,
    ) -> str:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待撤回发单订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        action_texts = ["撤回发单", "撤回派单", "取消发单", "撤销派单", "撤销发单", "撤回", "撤销"]
        clicked = False
        for action_text in action_texts:
            clicked = self.click_workshop_order_list_action(frame, source_order_no, action_text)
            if clicked:
                break
        if not clicked:
            clicked_operation = (
                self.click_workshop_order_list_action(frame, source_order_no, "操作")
                or self.click_row_action(frame, source_order_no, order_title, ["操作", "操 作"])
            )
            assert clicked_operation, (
                f"工作室履约应用-订单列表未找到撤回发单入口或操作按钮；source_order_no={source_order_no}; "
                f"row_text={row_text[:1500]}"
            )
            self.page.wait_for_timeout(1000)
            clicked = self.click_overlay_action(frame, action_texts)
        assert clicked, (
            f"订单操作弹框未找到撤回/取消发单动作；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}; row_text={row_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        if modal_text:
            self.fill_visible_modal_inputs_if_present(frame, reason)
            self.fill_modal_textarea_if_present(frame, reason)
            if self.visible_modal_has_file_input(frame):
                self.upload_single_modal_image(frame)
            submitted = self.click_overlay_action(
                frame,
                ["确认撤回", "确认撤销", "确认取消", "提交申请", "确认提交", "提交", "确定", "确认"],
            )
            if not submitted:
                self.confirm_open_dialog(frame)
        else:
            self.confirm_open_dialog(frame)

        self.page.wait_for_timeout(4000)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in final_text or order_title in final_text, (
            f"撤回发单后丸友集订单列表未找到订单；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        assert any(key in final_row_text or key in final_text for key in ["立即发单", "已接单", "撤回", "取消", "撤销", "发单"]), (
            f"撤回发单后未看到可重新发单或撤回结果；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def apply_revoke_upstream_order_from_workshop_list(
        self,
        source_order_no: str,
        order_title: str,
        reason: str,
        revoke_amount: str = "1",
    ) -> str:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待申请撤销订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        row_status = self.extract_workshop_order_row_status(row_text)
        if any(status in row_status for status in ["撤销中", "待上游", "待处理"]):
            return before_text
        assert "已接单" in row_status or "代练中" in row_status or "异常中" in row_status or "立即发单" in row_text, (
            f"S-FLOW-015 前置状态不满足丸友集已接单未发打手；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}"
        )
        clicked = self.click_workshop_order_dropdown_action(frame, source_order_no, order_title, "申请撤销")
        if not clicked:
            clicked = self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, "申请撤销")
        if not clicked:
            clicked = self.click_workshop_order_list_action(frame, source_order_no, "申请撤销")
        assert clicked, (
            f"工作室履约应用-订单列表未找到申请撤销按钮；source_order_no={source_order_no}; "
            f"text={before_text[:1500]}"
        )
        modal_text = self.wait_for_revoke_order_form_text(frame)
        assert modal_text, (
            f"点击申请撤销后未打开撤销弹框；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if "当前订单信息" in modal_text or "可进行的操作" in modal_text:
            clicked_revoke = self.click_overlay_action(frame, ["申请撤销", "撤销"])
            assert clicked_revoke, (
                f"订单操作弹框未找到申请撤销按钮；source_order_no={source_order_no}; "
                f"modal={modal_text[:1500]}"
            )
            modal_text = self.wait_for_revoke_order_form_text(frame)
            assert modal_text, (
                f"点击订单操作内申请撤销后未进入撤销原因表单；source_order_no={source_order_no}; "
                f"modal={modal_text[:1500]}"
            )
        self.fill_revoke_order_modal_fields(frame, reason=reason, amount=revoke_amount)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        clicked_submit = self.click_overlay_action(
            frame,
            ["确认撤销", "确认申请", "提交申请", "确认提交", "提交", "确定", "确认"],
        )
        assert clicked_submit, (
            f"申请撤销弹框未找到提交按钮；source_order_no={source_order_no}; modal={modal_text[:1500]}"
        )
        self.confirm_open_dialog(frame)
        final_text = ""
        final_row_text = ""
        final_row_status = ""
        for _ in range(8):
            self.page.wait_for_timeout(2500)
            final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
            final_row_text = self.extract_order_row_text(final_text, source_order_no)
            final_row_status = self.extract_workshop_order_row_status(final_row_text)
            if any(key in final_row_status for key in ["撤销", "异常", "待上游", "待处理", "已取消"]):
                break
        assert source_order_no in final_text or order_title in final_text, (
            f"申请撤销后丸友集订单列表未找到订单；source_order_no={source_order_no}; "
            f"text={final_text[:1500]}"
        )
        assert any(key in final_row_status for key in ["撤销", "异常", "待上游", "待处理", "已取消"]), (
            f"申请撤销提交后未看到撤销/异常/待处理状态变化；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def click_workshop_order_row_visible_text(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_text: str,
    ) -> bool:
        keywords = [item for item in [source_order_no, order_title] if item]
        row_selectors = "tbody tr.ant-table-row, tbody tr, tr, [class*='row'], [class*='order'], [class*='card']"
        for keyword in keywords:
            rows = frame.locator(row_selectors).filter(has_text=keyword)
            for row_index in range(min(rows.count(), 5)):
                row = rows.nth(row_index)
                action = row.get_by_text(action_text, exact=True)
                if action.count() == 0:
                    action = row.locator("button, a, [role='button'], span, div").filter(has_text=action_text)
                for action_index in range(action.count() - 1, -1, -1):
                    target = action.nth(action_index)
                    try:
                        if not target.is_visible(timeout=1000):
                            continue
                        target.scroll_into_view_if_needed(timeout=3000)
                        target.click(force=True, timeout=3000)
                        self.page.wait_for_timeout(1200)
                        if self.visible_modal_text(frame):
                            return True
                        if action_text in {"操作", "操 作", "订单详情", "详情"}:
                            continue
                        return True
                    except Exception:
                        continue

        clicked = frame.evaluate(
            """({ keywords, actionText }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll(
                  'tbody tr.ant-table-row, tbody tr, tr, [class*="row"], [class*="order"], [class*="card"]'
                ))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.some(keyword => item.text.includes(keyword)))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                for (const item of rows) {
                  const nodes = Array.from(item.row.querySelectorAll('button, a, [role="button"], span, div'))
                    .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                    .filter(node => visible(node.el))
                    .filter(node => node.text === actionText || node.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, ''))
                    .sort((a, b) => {
                      const aInteractive = a.el.closest('button, a, [role="button"]') ? 0 : 1;
                      const bInteractive = b.el.closest('button, a, [role="button"]') ? 0 : 1;
                      return aInteractive - bInteractive || (a.r.width * a.r.height) - (b.r.width * b.r.height) || b.r.x - a.r.x;
                    });
                  for (const target of nodes) {
                    const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                    const r = target.r;
                    clickable.scrollIntoView({ block: 'center', inline: 'center' });
                    clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                    clickable.click();
                    if (!['操作', '操 作', '订单详情', '详情'].includes(actionText)) return true;
                    const hasModal = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                      .some(el => {
                        const box = el.getBoundingClientRect();
                        return box.width > 0 && box.height > 0;
                      });
                    if (hasModal) return true;
                  }
                }
                return false;
            }""",
            {"keywords": keywords, "actionText": action_text},
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def apply_revoke_after_player_dispatch_from_workshop_list(
        self,
        source_order_no: str,
        order_title: str,
        reason: str,
        revoke_amount: str = "1",
    ) -> str:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到已发打手待撤销订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        row_status = self.extract_workshop_order_row_status(row_text)
        if any(status in row_status for status in ["撤销中", "待上游", "待处理"]):
            return before_text
        assert any(
            keyword in row_status or keyword in row_text
            for keyword in ["已发单", "已发布", "抢单大厅", "待接单", "服务中", "代练中", "撤销此单", "申请撤销"]
        ), (
            f"S-FLOW-075 前置状态不满足丸友集已发打手且打手未接单；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}"
        )

        action_texts = ["撤销此单", "申请撤销", "向上撤销"]
        clicked = False
        for action_text in action_texts:
            clicked = (
                self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, action_text)
                or self.click_workshop_order_list_action(frame, source_order_no, action_text)
                or self.click_danger_row_action(frame, [source_order_no, order_title], [action_text])
            )
            if clicked:
                break
        if not clicked:
            clicked_operation = (
                self.click_workshop_order_list_action(frame, source_order_no, "操作")
                or self.click_workshop_order_list_action(frame, source_order_no, "更多")
                or self.click_row_action_allow_revoke(frame, source_order_no, order_title, ["操作", "操 作", "更多"])
            )
            if clicked_operation:
                self.page.wait_for_timeout(1000)
                clicked = self.click_overlay_action(frame, action_texts + ["撤销"])
        assert clicked, (
            f"工作室履约应用-订单列表未找到撤销此单/申请撤销按钮；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={before_text[:1500]}"
        )

        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text, (
            f"点击撤销此单/申请撤销后未打开撤销弹框；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if "当前订单信息" in modal_text or "可进行的操作" in modal_text:
            clicked_revoke = self.click_overlay_action(frame, ["撤销此单", "申请撤销", "撤销"])
            assert clicked_revoke, (
                f"订单操作弹框未找到撤销此单/申请撤销按钮；source_order_no={source_order_no}; "
                f"modal={modal_text[:1500]}"
            )
            self.page.wait_for_timeout(1200)
            modal_text = self.visible_modal_text(frame)
            assert modal_text and ("原因" in modal_text or "说明" in modal_text or "备注" in modal_text or "上传" in modal_text), (
                f"点击订单操作内撤销后未进入撤销原因表单；source_order_no={source_order_no}; "
                f"modal={modal_text[:1500]}"
            )

        self.fill_revoke_order_modal_fields(frame, reason=reason, amount=revoke_amount)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        clicked_submit = self.click_overlay_action(
            frame,
            ["确认撤销", "确认申请", "提交申请", "确认提交", "提交", "确定", "确认"],
        )
        assert clicked_submit, (
            f"申请撤销弹框未找到提交按钮；source_order_no={source_order_no}; modal={modal_text[:1500]}"
        )
        self.page.wait_for_timeout(3500)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in final_text or order_title in final_text, (
            f"申请撤销后丸友集订单列表未找到订单；source_order_no={source_order_no}; "
            f"text={final_text[:1500]}"
        )
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        final_row_status = self.extract_workshop_order_row_status(final_row_text)
        assert any(key in final_row_status or key in final_row_text for key in ["撤销", "异常", "待上游", "待处理", "已取消"]), (
            f"申请撤销提交后未看到撤销/异常/待处理状态变化；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def approve_player_revoke_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        remark: str,
        sub_order_no: str = "",
    ) -> str:
        return self.handle_player_revoke_from_workshop_order(
            source_order_no,
            order_title,
            action_texts=["同意撤销", "同意", "审核通过", "通过", "处理撤销", "撤销处理", "去处理", "处理"],
            radio_text="同意",
            remark=remark,
            expected_keywords=["撤销完成", "已撤销", "撤销结束", "终"],
            sub_order_no=sub_order_no,
        )

    def reject_player_revoke_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        remark: str,
        sub_order_no: str = "",
    ) -> str:
        return self.handle_player_revoke_from_workshop_order(
            source_order_no,
            order_title,
            action_texts=["拒绝撤销", "拒绝", "驳回", "处理撤销", "撤销处理", "去处理", "处理"],
            radio_text="拒绝",
            remark=remark,
            expected_keywords=["代练中", "服务中", "已拒绝", "拒绝"],
            sub_order_no=sub_order_no,
        )

    def approve_player_cancel_order_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        remark: str,
        sub_order_no: str = "",
    ) -> str:
        return self.handle_player_cancel_order_from_workshop_order(
            source_order_no,
            order_title,
            action_texts=["同意取消", "同意", "审核通过", "通过", "处理取消", "取消处理", "去处理", "处理"],
            radio_text="同意取消",
            remark=remark,
            expected_keywords=["已取消", "取消订单", "取消完成", "终"],
            sub_order_no=sub_order_no,
        )

    def reject_player_cancel_order_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        remark: str,
        sub_order_no: str = "",
    ) -> str:
        return self.handle_player_cancel_order_from_workshop_order(
            source_order_no,
            order_title,
            action_texts=["拒绝取消", "拒绝", "驳回", "处理取消", "取消处理", "去处理", "处理"],
            radio_text="拒绝取消",
            remark=remark,
            expected_keywords=["代练中", "服务中", "已拒绝", "拒绝"],
            sub_order_no=sub_order_no,
        )

    def handle_player_cancel_order_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        action_texts: list[str],
        radio_text: str,
        remark: str,
        expected_keywords: list[str],
        sub_order_no: str = "",
    ) -> str:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待处理打手取消订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        before_row_text = self.extract_order_row_text(before_text, source_order_no)
        if radio_text in {"同意", "同意取消"} and any(key in before_row_text for key in expected_keywords):
            return before_text
        if radio_text in {"拒绝", "拒绝取消"} and "取消中" not in before_row_text and any(key in before_row_text for key in expected_keywords):
            return before_text

        clicked = False
        if sub_order_no:
            opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
            if opened:
                self.scroll_sub_order_table_to_right(frame)
                clicked = self.click_sub_order_row_action_by_no(frame, sub_order_no, action_texts)
                if clicked:
                    clicked = self.wait_for_player_cancel_process_modal(frame, source_order_no, order_title, sub_order_no)
                assert clicked, (
                    f"工作室履约应用未能进入目标打手子单取消处理弹窗；source_order_no={source_order_no}; "
                    f"sub_order_no={sub_order_no}; detail={self.visible_modal_text(frame)[:1500]}"
                )
        for action_text in action_texts:
            if clicked:
                break
            clicked = self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, action_text)
        if not clicked:
            clicked = self.click_row_action_allow_revoke(frame, source_order_no, order_title, action_texts)
        if not clicked:
            opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
            if opened:
                self.scroll_sub_order_table_to_right(frame)
                clicked = self.click_overlay_action(frame, action_texts)
        assert clicked, (
            f"工作室履约应用未找到打手取消处理入口；source_order_no={source_order_no}; "
            f"actions={action_texts}; text={before_text[:1500]}; detail={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text, (
            f"点击打手取消处理入口后未出现处理弹窗；source_order_no={source_order_no}; "
            f"actions={action_texts}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        assert self.click_modal_radio_text(frame, radio_text), (
            f"处理打手取消弹框未能选择审核结果【{radio_text}】；modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.fill_visible_modal_inputs_if_present(frame, remark)
        self.fill_modal_textarea_if_present(frame, remark)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_latest_modal_footer_button(frame, ["确认提交", "确认", "确定", "提交", "保 存", "保存"])
        if not submitted:
            submitted = self.click_overlay_action(frame, ["确认提交", "确认", "确定", "提交", "保 存", "保存"])
        if not submitted:
            self.confirm_open_dialog(frame)

        self.page.wait_for_timeout(3500)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        final_modal_text = self.visible_modal_text(frame)
        success_text = "\n".join([final_row_text, final_modal_text])
        assert any(key in success_text for key in expected_keywords), (
            f"处理打手取消后状态不符合预期；source_order_no={source_order_no}; "
            f"expected={expected_keywords}; row_text={final_row_text[:1500]}; "
            f"modal={final_modal_text[:1500]}; text={final_text[:1500]}"
        )
        return success_text or final_text

    def handle_player_revoke_from_workshop_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        action_texts: list[str],
        radio_text: str,
        remark: str,
        expected_keywords: list[str],
        sub_order_no: str = "",
    ) -> str:
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待处理打手撤销订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        before_row_text = self.extract_order_row_text(before_text, source_order_no)
        if radio_text == "同意" and any(key in before_row_text for key in expected_keywords):
            return before_text
        if radio_text == "拒绝" and "撤销中" not in before_row_text and any(key in before_row_text for key in expected_keywords):
            return before_text

        clicked = False
        if sub_order_no:
            opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
            if opened:
                self.scroll_sub_order_table_to_right(frame)
                clicked = self.click_sub_order_row_action_by_no(frame, sub_order_no, action_texts)
                if clicked:
                    clicked = self.wait_for_player_revoke_process_modal(frame, source_order_no, order_title, sub_order_no)
                assert clicked, (
                    f"工作室履约应用未能进入目标打手子单撤销处理弹窗；source_order_no={source_order_no}; "
                    f"sub_order_no={sub_order_no}; detail={self.visible_modal_text(frame)[:1500]}"
                )
        for action_text in action_texts:
            if clicked:
                break
            clicked = self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, action_text)
            if clicked:
                break
        if not clicked:
            clicked = self.click_row_action_allow_revoke(frame, source_order_no, order_title, action_texts)
        if not clicked:
            opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
            if opened:
                self.scroll_sub_order_table_to_right(frame)
                clicked = self.click_overlay_action(frame, action_texts)
        assert clicked, (
            f"工作室履约应用未找到打手撤销处理入口；source_order_no={source_order_no}; "
            f"actions={action_texts}; text={before_text[:1500]}; detail={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text, (
            f"点击打手撤销处理入口后未出现处理弹窗；source_order_no={source_order_no}; "
            f"actions={action_texts}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if modal_text:
            self.click_modal_radio_text(frame, radio_text)
            if "撤销审核" in modal_text:
                amount = "70" if radio_text in {"拒绝", "拒绝撤销", "驳回"} else "0"
                self.fill_revoke_audit_modal(frame, remark=remark, amount=amount)
            else:
                self.fill_visible_modal_inputs_if_present(frame, remark)
            self.fill_modal_textarea_if_present(frame, remark)
            if self.visible_modal_has_file_input(frame):
                self.upload_single_modal_image(frame)
            if "撤销审核" in modal_text:
                self.submit_current_modal(frame, ["同意撤销", "确认同意", "确认提交", "确认", "确定", "提交", "保 存", "保存"])
            else:
                submitted = self.click_latest_modal_footer_button(frame, ["确认提交", "确认", "确定", "提交", "保 存", "保存"])
                if not submitted:
                    submitted = self.click_overlay_action(frame, ["确认提交", "确认", "确定", "提交", "保 存", "保存"])
                if not submitted:
                    self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        final_modal_text = self.visible_modal_text(frame)
        assert any(key in final_row_text for key in expected_keywords), (
            f"处理打手撤销后状态不符合预期；source_order_no={source_order_no}; "
            f"expected={expected_keywords}; row_text={final_row_text[:1500]}; "
            f"modal={final_modal_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def fill_player_revoke_audit_modal(self, frame: Frame, *, radio_text: str, remark: str) -> None:
        frame.evaluate(
            """({ radioText, remark }) => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
                  if (setter) setter.call(el, value);
                  else el.value = value;
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                };
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const labels = Array.from(root.querySelectorAll('label, .ant-radio-wrapper, span, div'))
                  .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const radio = labels.find(item => item.text === norm(radioText) || item.text.includes(norm(radioText)));
                radio?.el.click();
                const formItems = Array.from(root.querySelectorAll('.ant-form-item, [class*="form-item"]'))
                  .map(el => ({ el, text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const amountItem = formItems.find(item => item.text.includes('结算金额'));
                const amountInput = amountItem?.el.querySelector('input:not([disabled]):not([type="file"]):not([type="hidden"])');
                if (amountInput) setValue(amountInput, radioText.includes('拒绝') || radioText.includes('驳回') ? '70' : '0');
                const textarea = root.querySelector('textarea:not([disabled])');
                if (textarea) setValue(textarea, remark);
            }""",
            {"radioText": radio_text, "remark": remark},
        )
        self.page.wait_for_timeout(500)

    def wait_for_player_revoke_process_modal(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        sub_order_no: str,
        timeout_ms: int = 5000,
    ) -> bool:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if last_text and (
                ("同意" in last_text and ("拒绝" in last_text or "驳回" in last_text))
                or "确认提交" in last_text
                or "处理撤销" in last_text
                or "撤销审核" in last_text
            ) and not ("子单列表" in last_text and sub_order_no in last_text and "去处理" in last_text):
                return True
            self.page.wait_for_timeout(500)

        for action_text in ["去处理", "处理撤销", "撤销处理", "处理"]:
            if self.click_overlay_action(frame, [action_text]):
                for _ in range(max(1, timeout_ms // 500)):
                    last_text = self.visible_modal_text(frame)
                    if last_text and (
                        ("同意" in last_text and ("拒绝" in last_text or "驳回" in last_text))
                        or "确认提交" in last_text
                        or "处理撤销" in last_text
                        or "撤销审核" in last_text
                    ) and not ("子单列表" in last_text and sub_order_no in last_text and "去处理" in last_text):
                        return True
                    self.page.wait_for_timeout(500)
        return False

    def wait_for_player_cancel_process_modal(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        sub_order_no: str,
        timeout_ms: int = 5000,
    ) -> bool:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if last_text and (
                ("同意" in last_text and ("拒绝" in last_text or "驳回" in last_text))
                or "确认提交" in last_text
                or "处理取消" in last_text
                or "取消审核" in last_text
            ) and not ("子单列表" in last_text and sub_order_no in last_text and "去处理" in last_text):
                return True
            self.page.wait_for_timeout(500)

        for action_text in ["去处理", "处理取消", "取消处理", "处理"]:
            if self.click_overlay_action(frame, [action_text]):
                for _ in range(max(1, timeout_ms // 500)):
                    last_text = self.visible_modal_text(frame)
                    if last_text and (
                        ("同意" in last_text and ("拒绝" in last_text or "驳回" in last_text))
                        or "确认提交" in last_text
                        or "处理取消" in last_text
                        or "取消审核" in last_text
                    ) and not ("子单列表" in last_text and sub_order_no in last_text and "去处理" in last_text):
                        return True
                    self.page.wait_for_timeout(500)
        return False

    def click_sub_order_row_action_by_no(
        self,
        frame: Frame,
        sub_order_no: str,
        action_texts: list[str],
    ) -> bool:
        if not sub_order_no:
            return False
        clicked = frame.evaluate(
            """({ subOrderNo, actionTexts }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const targets = actionTexts.map(norm);
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(visible);
                const searchRoot = roots.pop() || document;
                const rows = Array.from(searchRoot.querySelectorAll('tbody tr.ant-table-row, tbody tr, tr'))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(subOrderNo));
                const rowItem = rows[0];
                const row = rowItem?.row;
                if (!row) return false;
                const collectNodes = root => Array.from(root.querySelectorAll('button, a, [role="button"], span, div'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    const text = (el.innerText || el.textContent || '').trim();
                    const clickableText = (clickable.innerText || clickable.textContent || text).trim();
                    const r = el.getBoundingClientRect();
                    return { el, clickable, text, clickableText, r, disabled: Boolean(clickable.disabled) };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                let nodes = collectNodes(row);
                if (!nodes.some(item => targets.some(target =>
                    norm(item.text) === target || norm(item.clickableText) === target ||
                    norm(item.text).includes(target) || norm(item.clickableText).includes(target)
                  ))) {
                  const rowCenterY = rowItem.r.y + rowItem.r.height / 2;
                  const siblingRows = Array.from(searchRoot.querySelectorAll('tbody tr.ant-table-row, tbody tr, tr'))
                    .map(candidate => ({ row: candidate, text: candidate.innerText || candidate.textContent || '', r: candidate.getBoundingClientRect() }))
                    .filter(item => item.r.width > 0 && item.r.height > 0)
                    .filter(item => !item.text.includes(subOrderNo))
                    .filter(item => Math.abs((item.r.y + item.r.height / 2) - rowCenterY) < Math.max(8, rowItem.r.height / 2));
                  nodes = nodes.concat(...siblingRows.map(item => collectNodes(item.row)));
                }
                nodes = nodes
                  .filter(item => targets.some(target => norm(item.text) === target || norm(item.clickableText) === target || norm(item.text).includes(target) || norm(item.clickableText).includes(target)))
                  .sort((a, b) => {
                    const aExact = targets.includes(norm(a.text)) || targets.includes(norm(a.clickableText)) ? 0 : 1;
                    const bExact = targets.includes(norm(b.text)) || targets.includes(norm(b.clickableText)) ? 0 : 1;
                    const aInteractive = a.el.closest('button, a, [role="button"]') ? 0 : 1;
                    const bInteractive = b.el.closest('button, a, [role="button"]') ? 0 : 1;
                    return aExact - bExact || aInteractive - bInteractive || a.text.length - b.text.length || b.r.x - a.r.x;
                  });
                for (const targetText of targets) {
                  const target = nodes.find(item => norm(item.text) === targetText || norm(item.clickableText) === targetText) ||
                    nodes.find(item => norm(item.text).includes(targetText) || norm(item.clickableText).includes(targetText));
                  if (!target) continue;
                  target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                  target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                  target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                  target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                  target.clickable.click();
                  return true;
                }
                return false;
            }""",
            {"subOrderNo": sub_order_no, "actionTexts": action_texts},
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def assert_player_revoke_target_sub_order_terminal(
        self,
        source_order_no: str,
        order_title: str,
        sub_order_no: str = "",
    ) -> tuple[str, str]:
        frame = self.open_workshop_order_list()
        list_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in list_text or order_title in list_text, (
            f"丸友集订单列表未找到待复核打手撤销结果订单；source_order_no={source_order_no}; "
            f"text={list_text[:1500]}"
        )
        opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
        assert opened, (
            f"丸友集订单列表未能打开订单详情复核子单状态；source_order_no={source_order_no}; "
            f"text={list_text[:1500]}"
        )
        self.scroll_sub_order_table_to_right(frame)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        if not sub_order_no:
            sub_order_no = self.latest_sub_order_no_by_status(detail_text, ["已撤销", "撤销完成", "撤销结束"])
        target_row = self.extract_sub_order_row_from_modal_text(detail_text, sub_order_no)
        assert sub_order_no and target_row, (
            f"丸友集详情未定位到目标打手子单；source_order_no={source_order_no}; "
            f"sub_order_no={sub_order_no}; detail={detail_text[:1500]}"
        )
        assert "撤销中" not in target_row and any(status in target_row for status in ["已撤销", "撤销完成", "撤销结束"]), (
            f"目标打手子单未完成撤销，不能进入二发；source_order_no={source_order_no}; "
            f"sub_order_no={sub_order_no}; row={target_row[:1500]}; detail={detail_text[:1500]}"
        )
        return sub_order_no, detail_text

    def assert_player_cancel_target_sub_order_terminal(
        self,
        source_order_no: str,
        order_title: str,
        sub_order_no: str = "",
    ) -> tuple[str, str]:
        frame = self.open_workshop_order_list()
        list_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in list_text or order_title in list_text, (
            f"丸友集订单列表未找到待复核打手取消结果订单；source_order_no={source_order_no}; "
            f"text={list_text[:1500]}"
        )
        opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
        assert opened, (
            f"丸友集订单列表未能打开订单详情复核取消子单状态；source_order_no={source_order_no}; "
            f"text={list_text[:1500]}"
        )
        self.scroll_sub_order_table_to_right(frame)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        if not sub_order_no:
            sub_order_no = self.latest_sub_order_no_by_status(detail_text, ["已取消", "取消订单", "取消完成"])
        target_row = self.extract_sub_order_row_from_modal_text(detail_text, sub_order_no)
        assert sub_order_no and target_row, (
            f"丸友集详情未定位到目标打手取消子单；source_order_no={source_order_no}; "
            f"sub_order_no={sub_order_no}; detail={detail_text[:1500]}"
        )
        assert "取消中" not in target_row and any(status in target_row for status in ["已取消", "取消订单", "取消完成"]), (
            f"目标打手子单未完成取消，不能进入二发；source_order_no={source_order_no}; "
            f"sub_order_no={sub_order_no}; row={target_row[:1500]}; detail={detail_text[:1500]}"
        )
        return sub_order_no, detail_text

    def find_workshop_sub_order_no_by_status(
        self,
        source_order_no: str,
        order_title: str,
        statuses: list[str],
    ) -> tuple[str, str]:
        frame = self.open_workshop_order_list()
        list_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in list_text or order_title in list_text, (
            f"丸友集订单列表未找到目标订单；source_order_no={source_order_no}; text={list_text[:1500]}"
        )
        opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
        assert opened, (
            f"丸友集订单列表未能打开订单详情定位子单；source_order_no={source_order_no}; "
            f"text={list_text[:1500]}"
        )
        self.scroll_sub_order_table_to_right(frame)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        sub_order_no = self.latest_sub_order_no_by_status(detail_text, statuses)
        assert sub_order_no, (
            f"丸友集订单详情未找到状态为 {statuses} 的子单；source_order_no={source_order_no}; "
            f"detail={detail_text[:1500]}"
        )
        return sub_order_no, detail_text

    @staticmethod
    def latest_sub_order_no_by_status(detail_text: str, statuses: list[str]) -> str:
        rows = []
        for match in re.finditer(r"WY\d{20,}", detail_text or ""):
            sub_order_no = match.group(0)
            next_match = re.search(r"\nWY\d{20,}", detail_text[match.end() :])
            end = match.end() + next_match.start() if next_match else len(detail_text)
            rows.append((sub_order_no, detail_text[match.start() : end]))
        for sub_order_no, row in rows:
            if any(status in row for status in statuses):
                return sub_order_no
        return ""

    def click_latest_modal_footer_button(self, frame: Frame, action_texts: list[str]) -> bool:
        script = """actionTexts => {
            const norm = value => String(value || '').replace(/\\s+/g, '');
            const targets = actionTexts.map(norm);
            const roots = Array.from(document.querySelectorAll('.ant-modal'))
              .filter(el => {
                const r = el.getBoundingClientRect();
                return r.width > 0 && r.height > 0;
              });
            const root = roots.pop();
            if (!root) return false;
            const searchRoots = [root.querySelector('.ant-modal-footer'), root].filter(Boolean);
            for (const searchRoot of searchRoots) {
              const buttons = Array.from(searchRoot.querySelectorAll('button, [role="button"]'))
                .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect(), disabled: el.disabled }))
                .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
              for (const targetText of targets) {
                const target = buttons.find(item => item.text === targetText) ||
                  buttons.find(item => item.text.includes(targetText));
                if (target) {
                  target.el.click();
                  return true;
                }
              }
            }
            return false;
        }"""
        clicked = bool(frame.evaluate(script, action_texts))
        if not clicked:
            clicked = bool(self.page.evaluate(script, action_texts))
        self.page.wait_for_timeout(1000)
        return clicked

    @staticmethod
    def extract_workshop_order_row_status(row_text: str) -> str:
        match = re.search(r"代练费支出总和：[^\n]*\n\t(?P<status>[^\n\t]+)", row_text or "")
        if match:
            return match.group("status").strip()
        for status in ["待上游处理", "待处理", "撤销中", "异常中", "已取消", "已接单", "代练中"]:
            if status in row_text:
                return status
        return ""

    def handle_upstream_aftersale_from_workshop_list(
        self,
        source_order_no: str,
        order_title: str,
        *,
        action: str,
        remark: str,
        amount: str = "1",
        service_name: str = "",
    ) -> str:
        assert action in {"同意", "拒绝"}, f"售后处理动作仅支持：同意/拒绝；当前值：{action}"
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待处理售后订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        assert "售后" in row_text or "售后" in before_text, (
            f"丸友集订单列表目标订单不是售后中；source_order_no={source_order_no}; row_text={row_text[:1500]}"
        )
        clicked = self.click_workshop_order_list_action(frame, source_order_no, "主单售后")
        if not clicked:
            clicked = self.click_row_action(frame, source_order_no, order_title, ["主单售后", "售后处理", "处理售后", "售后"])
        assert clicked, (
            f"工作室履约应用-订单列表未找到主单售后入口；source_order_no={source_order_no}; "
            f"text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text and "售后" in modal_text, (
            f"点击主单售后后未打开订单售后处理弹窗；source_order_no={source_order_no}; "
            f"modal={modal_text[:1500]}; page={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.fill_workshop_aftersale_handle_modal(frame, remark=remark, amount=amount, service_name=service_name)
        clicked_submit = self.click_workshop_aftersale_decision_button(frame, action)
        assert clicked_submit, (
            f"订单售后处理弹窗未找到【{action}】按钮；source_order_no={source_order_no}; "
            f"modal={modal_text[:1500]}"
        )
        if action == "拒绝":
            self.submit_workshop_aftersale_reject_reason(frame, remark)
        self.page.wait_for_timeout(3500)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        for _ in range(6):
            final_row_text = self.extract_order_row_text(final_text, source_order_no)
            if action == "拒绝" and "售后中" not in final_row_text:
                break
            if action == "同意" and ("售后完成" in final_row_text or "已售后" in final_row_text):
                break
            self.page.wait_for_timeout(2000)
            final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in final_text or order_title in final_text, (
            f"丸友集售后{action}后订单列表未找到订单；source_order_no={source_order_no}; "
            f"text={final_text[:1500]}"
        )
        return final_text

    def click_workshop_aftersale_decision_button(self, frame: Frame, action: str) -> bool:
        assert action in {"同意", "拒绝"}, f"售后处理动作仅支持：同意/拒绝；当前值：{action}"
        if action == "同意" and self.click_workshop_aftersale_approve_button_by_dom(frame):
            self.page.wait_for_timeout(1500)
            return True
        if action == "拒绝" and self.click_workshop_aftersale_reject_button_by_dom(frame):
            self.page.wait_for_timeout(1500)
            return True
        for scope in [frame, self.page]:
            modals = scope.locator(".ant-modal:visible")
            for index in range(modals.count() - 1, -1, -1):
                modal = modals.nth(index)
                modal_text = modal.inner_text(timeout=3000)
                if "售后" not in modal_text:
                    continue
                if action == "拒绝":
                    buttons = modal.locator("button.ant-btn-dangerous:visible").filter(has_text="拒")
                    if buttons.count() == 0:
                        buttons = modal.locator(".ant-modal-footer button:visible").filter(has_text="拒")
                else:
                    buttons = modal.locator("button.ant-btn-primary:visible").filter(has_text="同 意")
                    if buttons.count() == 0:
                        buttons = modal.locator("button.ant-btn-primary:visible").filter(has_text="同")
                    if buttons.count() == 0:
                        buttons = modal.locator(".ant-modal-footer button:visible").filter(has_text="同")
                    if buttons.count() == 0:
                        buttons = modal.locator("button:visible").filter(has_text="同")
                if buttons.count() == 0:
                    continue
                button = buttons.last
                button.click(force=True)
                self.page.wait_for_timeout(1500)
                return True
        self.page.wait_for_timeout(1200)
        return False

    def click_workshop_aftersale_reject_button_by_dom(self, frame: Frame) -> bool:
        script = """() => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const modals = Array.from(document.querySelectorAll('.ant-modal')).filter(visible);
            for (let i = modals.length - 1; i >= 0; i -= 1) {
              const modal = modals[i];
              const modalText = (modal.innerText || modal.textContent || '').trim();
              if (!modalText.includes('售后')) continue;
              const buttons = Array.from(modal.querySelectorAll('.ant-modal-footer button, button'))
                .filter(visible)
                .map(el => ({ el, text: (el.innerText || el.textContent || '').replace(/\\s+/g, '') }))
                .filter(item => item.text === '拒绝');
              const target = buttons.find(item => String(item.el.className || '').includes('danger')) || buttons[0];
              if (!target) continue;
              target.el.scrollIntoView({ block: 'center', inline: 'center' });
              target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
              target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
              target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
              target.el.click();
              return true;
            }
            return false;
        }"""
        for scope in [frame, self.page]:
            try:
                if bool(scope.evaluate(script)):
                    return True
            except Exception:
                continue
        return False

    def click_workshop_aftersale_approve_button_by_dom(self, frame: Frame) -> bool:
        script = """() => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const modals = Array.from(document.querySelectorAll('.ant-modal')).filter(visible);
            for (let i = modals.length - 1; i >= 0; i -= 1) {
              const modal = modals[i];
              const modalText = (modal.innerText || modal.textContent || '').trim();
              if (!modalText.includes('售后')) continue;
              const buttons = Array.from(modal.querySelectorAll('button.ant-btn-primary, button'))
                .filter(visible)
                .map(el => ({ el, text: (el.innerText || el.textContent || '').replace(/\\s+/g, '') }))
                .filter(item => item.text === '同意');
              const target = buttons.find(item => item.el.className.includes('ant-btn-primary')) || buttons[0];
              if (!target) continue;
              target.el.scrollIntoView({ block: 'center', inline: 'center' });
              target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
              target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
              target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
              target.el.click();
              return true;
            }
            return false;
        }"""
        for scope in [frame, self.page]:
            try:
                if bool(scope.evaluate(script)):
                    return True
            except Exception:
                continue
        return False

    def submit_workshop_aftersale_reject_reason(self, frame: Frame, reason: str) -> None:
        self.page.wait_for_timeout(800)
        last_modal_text = ""
        for _ in range(8):
            for scope in [frame, self.page]:
                modals = scope.locator(".ant-modal:visible")
                for index in range(modals.count() - 1, -1, -1):
                    modal = modals.nth(index)
                    modal_text = modal.inner_text(timeout=3000)
                    last_modal_text = modal_text
                    compact = re.sub(r"\s+", "", modal_text)
                    is_main_aftersale_modal = "订单售后处理" in modal_text and "主单售后处理" in modal_text
                    if is_main_aftersale_modal and "同意" in compact and "拒绝" in compact:
                        continue
                    if not any(keyword in modal_text for keyword in ["拒绝", "原因", "理由"]):
                        continue
                    control = modal.locator("textarea:visible").last
                    if control.count() == 0:
                        control = modal.locator("input:not([type='file']):not([type='hidden']):visible").last
                    assert control.count(), f"拒绝理由弹框未找到理由输入框；modal={modal_text[:1500]}"
                    control.fill(reason)
                    confirm = modal.locator("button:visible").filter(has_text="确认").last
                    if confirm.count() == 0:
                        confirm = modal.locator("button:visible").filter(has_text="确定").last
                    if confirm.count() == 0:
                        confirm = modal.locator("button:visible").filter(has_text="提交").last
                    assert confirm.count(), f"拒绝理由弹框未找到确认按钮；modal={modal_text[:1500]}"
                    confirm.click(force=True)
                    self.page.wait_for_timeout(1500)
                    return
            self.page.wait_for_timeout(500)
        raise AssertionError(f"点击【拒绝】后未出现拒绝理由弹框；modal={last_modal_text[:1500]}")

    def fill_workshop_aftersale_handle_modal(
        self,
        frame: Frame,
        *,
        remark: str,
        amount: str,
        service_name: str = "",
    ) -> None:
        self.fill_modal_amount_if_present(frame, amount)
        self.fill_visible_modal_inputs_if_present(frame, remark)
        self.select_workshop_aftersale_service(frame, service_name=service_name)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)

    def select_workshop_aftersale_service(self, frame: Frame, *, service_name: str = "") -> str:
        selected = self.open_workshop_aftersale_service_dropdown(frame)
        self.page.wait_for_timeout(800)
        if selected.get("options") == ["__FIELD_NOT_REQUIRED__"]:
            return ""
        options = self.visible_dropdown_options_in_frame_or_page(frame)
        if not options:
            self.record_optional_aftersale_service_bug(
                frame,
                "售后处理弹窗【售后客服】下拉没有可选数据，客服不是必填项，继续执行售后处理。",
                service_name=service_name,
            )
            return ""
        clicked = bool(
            frame.evaluate(
                """serviceName => {
                    const options = Array.from(document.querySelectorAll(
                      '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled)'
                    )).map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && item.text);
                    const target = options.find(item => serviceName && item.text.includes(serviceName)) || options[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }""",
                service_name,
            )
        )
        if not clicked:
            clicked = bool(
                self.page.evaluate(
                    """serviceName => {
                        const options = Array.from(document.querySelectorAll(
                          '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled)'
                        )).map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                          .filter(item => item.r.width > 0 && item.r.height > 0 && item.text);
                        const target = options.find(item => serviceName && item.text.includes(serviceName)) || options[0];
                        if (!target) return false;
                        target.el.click();
                        return true;
                    }""",
                    service_name,
                )
            )
        if not clicked:
            self.record_optional_aftersale_service_bug(
                frame,
                f"售后处理弹窗未能选择售后客服，客服不是必填项，继续执行售后处理；options={options}",
                service_name=service_name,
            )
            return ""
        self.page.wait_for_timeout(500)
        return options[0]

    def open_workshop_aftersale_service_dropdown(self, frame: Frame) -> dict:
        for scope in [frame, self.page]:
            modals = scope.locator(".ant-modal:visible")
            for index in range(modals.count() - 1, -1, -1):
                modal = modals.nth(index)
                modal_text = modal.inner_text(timeout=3000)
                fields = modal.locator(".ant-form-item:visible").filter(has_text="售后客服")
                if fields.count() == 0:
                    fields = modal.locator(".ant-form-item:visible").filter(has_text="客服")
                if fields.count() == 0:
                    continue
                field = fields.last
                arrow = field.locator(".ant-select-arrow:visible").last
                if arrow.count():
                    arrow.click(force=True)
                    return {"opened": True, "selected": "", "options": [], "modalText": modal_text}
                selector = field.locator(".ant-select-selector:visible, .ant-select:visible, input:visible").last
                if selector.count():
                    box = selector.bounding_box()
                    if box:
                        scope.mouse.click(box["x"] + box["width"] - 8, box["y"] + box["height"] / 2)
                    else:
                        selector.click(force=True)
                    return {"opened": True, "selected": "", "options": [], "modalText": modal_text}
                return {"opened": False, "selected": "", "options": [], "modalText": modal_text}

        modal_text = self.visible_modal_text(frame)
        if modal_text and "售后客服" not in modal_text and "客服" not in modal_text:
            return {"opened": True, "selected": "", "options": ["__FIELD_NOT_REQUIRED__"], "modalText": modal_text}
        return {"opened": False, "selected": "", "options": [], "modalText": modal_text}

    def record_optional_aftersale_service_bug(self, frame: Frame, message: str, *, service_name: str = "") -> None:
        if not self.bug_reporter:
            return
        self.bug_reporter.write_bug_report(
            account="丸友集",
            location=(
                "丸友集售后处理弹窗-售后客服下拉选不中；"
                f"service_name={service_name}; modal={self.visible_modal_text(frame)[:1000]}"
            ),
            frontend_errors=[message],
            network_issues=[],
        )

    def fill_revoke_order_modal_fields(self, frame: Frame, *, reason: str, amount: str) -> None:
        amount_value = str(amount).strip()
        assert amount_value.isdigit(), f"撤销金额必须填写整数数字；当前值：{amount}"
        result = frame.evaluate(
            """({ reason, amount }) => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const controls = Array.from(root.querySelectorAll('textarea, input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({
                    el,
                    placeholder: el.getAttribute('placeholder') || '',
                    type: el.getAttribute('type') || '',
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  el.focus();
                  setter.call(el, '');
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' }));
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                let amountCount = 0;
                let reasonCount = 0;
                for (const item of controls) {
                  const placeholder = item.placeholder;
                  const isAmount =
                    /金额|价格|退款|撤销金额|赔付|费用/.test(placeholder) ||
                    item.type === 'number';
                  if (isAmount) {
                    setValue(item.el, amount);
                    amountCount += 1;
                  }
                }
                for (const item of controls) {
                  const placeholder = item.placeholder;
                  const isAmount =
                    /金额|价格|退款|撤销金额|赔付|费用/.test(placeholder) ||
                    item.type === 'number';
                  if (isAmount) continue;
                  if (/原因|理由|备注|说明/.test(placeholder) || item.el.tagName === 'TEXTAREA') {
                    setValue(item.el, reason);
                    reasonCount += 1;
                  }
                }
                return { amountCount, reasonCount, modalText: root.innerText || root.textContent || '' };
            }""",
            {"reason": reason, "amount": amount_value},
        )
        self.page.wait_for_timeout(500)
        assert result and result.get("amountCount", 0) > 0, (
            f"申请撤销弹框未找到撤销金额输入框；modal={str((result or {}).get('modalText', ''))[:1500]}"
        )

    def approve_workshop_revoke_request(
        self,
        *,
        main_order_no: str,
        source_order_no: str,
        order_title: str,
        remark: str,
    ) -> str:
        frame = self.open_order_management()
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in before_text or order_title in before_text or (main_order_no and main_order_no in before_text), (
            f"丸掌柜订单管理未找到待同意撤销的订单；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        action_texts = ["同意撤销", "同意", "处理撤销", "撤销处理", "处理"]
        clicked = self.click_order_row_action(frame, source_order_no, order_title, action_texts)
        if not clicked:
            opened = self.open_main_order_detail_from_list(
                frame,
                main_order_no=main_order_no,
                source_order_no=source_order_no,
                order_title=order_title,
            )
            assert opened, (
                f"丸掌柜订单管理未能打开待同意撤销订单详情；main_order_no={main_order_no}; "
                f"source_order_no={source_order_no}; text={before_text[:1500]}"
            )
            clicked = self.click_sub_order_list_action_by_status(frame, ["撤销中", "异常中", "服务中"], action_texts)
            if not clicked:
                clicked = self.click_wanzhanggui_detail_action_text(frame, action_texts)
        assert clicked, (
            f"丸掌柜未找到同意撤销入口；main_order_no={main_order_no}; "
            f"source_order_no={source_order_no}; text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        if modal_text:
            self.click_modal_radio_text(frame, "同意")
            self.fill_visible_modal_inputs_if_present(frame, remark)
            self.submit_current_modal(frame, ["同意撤销", "确认同意", "提交", "确定", "确认", "保 存", "保存"])
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        self.search_order_by_best_identity(
            frame,
            main_order_no=main_order_no,
            source_order_no=source_order_no,
            order_title=order_title,
        )
        return frame.locator("body").inner_text(timeout=5000)

    def wait_for_dispatch_modal(self, frame: Frame, timeout_ms: int = 10000) -> bool:
        deadline = timeout_ms // 500
        for _ in range(max(1, deadline)):
            if self.dispatch_modal_visible(frame):
                return True
            self.page.wait_for_timeout(500)
        return False

    def dispatch_modal_visible(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    if (!roots.length) return false;
                    const text = (roots[roots.length - 1].innerText || roots[roots.length - 1].textContent || '').trim();
                    const placeholders = Array.from(roots[roots.length - 1].querySelectorAll('input, textarea'))
                      .map(el => el.getAttribute('placeholder') || '')
                      .join('\\n');
                    const modalSignal = [
                      '请添加子单',
                      '确认发单',
                      '确定发单',
                      '分配方式',
                      '固定发单价',
                      '百分比发单价',
                      '选择打手',
                      '发单价'
                    ].some(key => text.includes(key));
                    const fieldSignal = /请输入发单价|价格百分比|请输入代练时限|安全保证金|效率保证金/.test(placeholders);
                    return modalSignal || fieldSignal;
                }"""
            )
        )

    def click_modal_button(self, frame: Frame, texts: list[str]) -> bool:
        script = """texts => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), disabled: el.disabled, r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                for (const text of texts) {
                  const target = buttons.find(item => item.text === text) ||
                    buttons.find(item => item.text.includes(text));
                  if (target) {
                    target.el.click();
                    return true;
                  }
                }
                return false;
            }"""
        clicked = frame.evaluate(
            script,
            texts,
        )
        if not clicked:
            clicked = self.page.evaluate(script, texts)
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_modal_radio_text(self, frame: Frame, text: str) -> bool:
        script = """text => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const nodes = Array.from(root.querySelectorAll('label, .ant-radio-wrapper, .ant-checkbox-wrapper, span, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = nodes.find(item => item.text === text) ||
                  nodes.find(item => item.text.includes(text));
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        clicked = frame.evaluate(script, text)
        if not clicked:
            clicked = self.page.evaluate(script, text)
        self.page.wait_for_timeout(500)
        return bool(clicked)


    def verify_order_in_player_hall(self, source_order_no: str, order_title: str) -> str:
        frames = self.open_player_order_hall()
        summaries: list[str] = []
        for frame in frames:
            self.search_order_if_possible(frame, source_order_no)
            text = frame.locator("body").inner_text(timeout=5000)
            summaries.append(text[:1000])
            if source_order_no in text or order_title in text:
                return text
        raise AssertionError(
            f"打手应用-抢单大厅未找到管理员发出的订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; summaries={summaries}"
        )

    def grab_order_from_player_hall_and_verify_my_order(self, source_order_no: str, order_title: str) -> None:
        frames = self.open_player_order_hall()
        summaries: list[str] = []
        for frame in frames:
            self.search_order_if_possible(frame, source_order_no)
            text = frame.locator("body").inner_text(timeout=5000)
            summaries.append(text[:1200])
            if source_order_no not in text and order_title not in text:
                continue
            last_text = text
            for attempt in range(8):
                clicked = self.click_row_action(frame, source_order_no, order_title, ["立即抢单", "抢单"])
                assert clicked, (
                    f"打手应用-抢单大厅找到订单但未找到立即抢单按钮；"
                    f"source_order_no={source_order_no}; order_title={order_title}; text={last_text[:1500]}"
                )
                self.page.wait_for_timeout(1000)
                grabbed = self.confirm_player_grab_if_needed(frame)
                self.page.wait_for_timeout(2500)
                if grabbed:
                    self.verify_order_in_player_my_orders(source_order_no, order_title)
                    return
                last_text = frame.locator("body").inner_text(timeout=5000)
                if not self.order_still_waiting_player_grab(frame, source_order_no, order_title):
                    self.verify_order_in_player_my_orders(source_order_no, order_title)
                    return
                self.page.wait_for_timeout(500 + attempt * 200)
            raise AssertionError(
                f"打手多次点击立即抢单后订单仍停留在抢单大厅，疑似图形验证码未通过；"
                f"source_order_no={source_order_no}; order_title={order_title}; text={last_text[:1500]}"
            )
        self.verify_order_in_player_my_orders(source_order_no, order_title)

    def apply_cancel_assigned_order_from_player_hall(
        self,
        *,
        assigned_order_no: str,
        source_order_no: str,
        order_title: str,
        reason: str,
    ) -> str:
        frames = self.open_player_assigned_order_hall()
        summaries: list[str] = []
        query_no = assigned_order_no or source_order_no
        for frame in frames:
            self.search_player_assigned_orders(frame, query_no)
            text = frame.locator("body").inner_text(timeout=5000)
            summaries.append(text[:1500])
            if query_no not in text and source_order_no not in text and order_title not in text:
                continue
            clicked = self.click_row_action(frame, query_no, "", ["操作"])
            assert clicked, (
                f"打手抢单大厅-指派单找到订单但未找到操作按钮；"
                f"assigned_order_no={assigned_order_no}; source_order_no={source_order_no}; text={text[:1500]}"
            )
            self.page.wait_for_timeout(1000)
            action_text = frame.locator("body").inner_text(timeout=5000)
            assert "申请取消" in action_text, (
                f"打手抢单大厅-指派单操作弹窗未展示申请取消；"
                f"assigned_order_no={assigned_order_no}; text={action_text[:1500]}"
            )
            assert self.click_overlay_action(frame, ["申请取消"]), (
                f"打手抢单大厅-指派单操作弹窗未点到申请取消；"
                f"assigned_order_no={assigned_order_no}; text={action_text[:1500]}"
            )
            self.page.wait_for_timeout(1000)
            self.fill_visible_modal_inputs_if_present(frame, reason)
            self.fill_modal_textarea_if_present(frame, reason)
            if self.visible_modal_has_file_input(frame):
                self.upload_single_modal_image(frame)
            submitted = self.click_assigned_cancel_confirm_button(frame)
            assert submitted, (
                f"打手抢单大厅-指派单申请取消弹框未点到蓝色确认按钮；"
                f"assigned_order_no={assigned_order_no}; modal={self.visible_modal_text(frame)[:1500]}"
            )
            self.page.wait_for_timeout(2500)
            return frame.locator("body").inner_text(timeout=5000)
        raise AssertionError(
            f"打手抢单大厅-指派单未找到待申请取消订单；assigned_order_no={assigned_order_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; summaries={summaries}"
        )

    def click_assigned_cancel_confirm_button(self, frame: Frame) -> bool:
        script = """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const norm = text => String(text || '').replace(/\\s+/g, '');
                const modals = Array.from(document.querySelectorAll('.ant-modal, [role="dialog"]'))
                  .filter(visible)
                  .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                  .filter(item => /申请取消|取消原因|原因/.test(item.text));
                const modal = modals.pop();
                if (!modal) return false;
                const footer = modal.el.querySelector('.ant-modal-footer') || modal.el;
                const buttons = Array.from(footer.querySelectorAll('button, [role="button"]'))
                  .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = buttons.find(item => /确认/.test(item.text) && String(item.el.className || '').includes('primary')) ||
                  buttons.find(item => item.text === '确认') ||
                  buttons.find(item => /确认申请|确认取消|确认提交|提交|确定/.test(item.text)) ||
                  buttons[buttons.length - 1];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                const r = target.r;
                clickable.scrollIntoView({ block: 'center', inline: 'center' });
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }"""
        clicked = bool(frame.evaluate(script))
        if not clicked:
            clicked = bool(self.page.evaluate(script))
        self.page.wait_for_timeout(1000)
        return clicked

    def assigned_order_still_waiting_accept(
        self,
        frame: Frame,
        assigned_order_no: str,
        source_order_no: str,
        order_title: str,
    ) -> bool:
        try:
            text = frame.locator("body").inner_text(timeout=5000)
        except Exception:
            return False
        if assigned_order_no not in text and source_order_no not in text and order_title not in text:
            return False
        return "待接单" in text or "立即接单" in text or "\n接单" in text

    def search_player_assigned_orders(self, frame: Frame, assigned_order_no: str) -> str:
        self.reset_search_if_possible(frame)
        if not self.fill_input_near_label(frame, "订单号", assigned_order_no):
            assert self.fill_first_filter_input(frame, assigned_order_no), (
                f"打手抢单大厅-指派单未找到订单号输入框；assigned_order_no={assigned_order_no}; "
                f"text={frame.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")
        self.page.wait_for_timeout(2000)
        return frame.locator("body").inner_text(timeout=5000)

    def confirm_player_grab_if_needed(self, frame: Frame) -> bool:
        text = frame.locator("body").inner_text(timeout=5000)
        if "确认" not in text and "抢单" not in text and "图形验证码" not in text:
            return True
        return self.confirm_grab_order_dialog(frame, numeric_only=True)

    def order_still_waiting_player_grab(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        try:
            text = frame.locator("body").inner_text(timeout=5000)
        except Exception:
            return False
        if source_order_no not in text and order_title not in text:
            return False
        return "立即抢单" in text or ("待接单" in text and "抢单" in text)

    def verify_order_in_player_my_orders(self, source_order_no: str, order_title: str) -> None:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"打手应用-我的订单未找到抢单后的订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )

    def apply_player_revoke_from_my_orders(self, source_order_no: str, order_title: str, reason: str) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        before_text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"打手应用-我的订单未找到待申请撤销订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        if "撤销中" in row_text:
            return before_text

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in modal_text for key in ["申请撤销", "撤销", "取消订单"]), (
            f"打手订单操作中未找到申请撤销入口；source_order_no={source_order_no}; text={modal_text[:1500]}"
        )
        action = "申请撤销" if "申请撤销" in modal_text else "撤销"
        self.click_player_order_modal_action(frame, action)
        self.page.wait_for_timeout(1000)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        self.fill_modal_amount_if_present(frame, os.getenv("E2E_S_FLOW_PLAYER_REVOKE_AMOUNT", "1"))
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_overlay_action(
            frame,
            ["确认撤销", "确认申请", "提交申请", "确认提交", "提交", "确定", "确认"],
        )
        if not submitted:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_player_my_orders(frame, source_order_no, order_title)
        final_row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        assert any(key in final_row_text for key in ["撤销中", "待审核", "审核中"]), (
            f"打手申请撤销后未看到撤销中状态；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def apply_player_cancel_order_from_my_orders(self, source_order_no: str, order_title: str, reason: str) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        before_text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"打手应用-我的订单未找到待取消订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        if "取消中" in row_text:
            return before_text
        assert any(key in row_text for key in ["代练中", "服务中", "已接单"]), (
            f"S-FLOW-037 前置状态不满足打手代练中/服务中；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={before_text[:1500]}"
        )

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        action_texts = ["取消订单", "申请取消", "取消此单", "发起取消"]
        assert any(key in modal_text for key in action_texts), (
            f"打手订单操作中未找到取消订单入口；source_order_no={source_order_no}; text={modal_text[:1500]}"
        )
        for action in action_texts:
            if action in modal_text:
                self.click_player_order_modal_action(frame, action)
                break
        self.page.wait_for_timeout(1000)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_overlay_action(
            frame,
            ["确认取消", "确认申请", "提交申请", "确认提交", "提交", "确定", "确认"],
        )
        if not submitted:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_player_my_orders(frame, source_order_no, order_title)
        final_row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        assert any(key in final_row_text for key in ["取消中", "待审核", "审核中"]), (
            f"打手申请取消订单后未看到取消中/待审核状态；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def cancel_player_revoke_from_my_orders(self, source_order_no: str, order_title: str, reason: str) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        before_text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"打手应用-我的订单未找到待取消撤销订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        if "撤销中" not in row_text:
            assert any(key in row_text for key in ["代练中", "服务中", "已接单"]), (
                f"S-FLOW-028 前置状态既不是撤销中，也未恢复代练中；"
                f"source_order_no={source_order_no}; text={before_text[:1500]}"
            )
            return before_text

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in modal_text for key in ["取消撤销", "撤销取消", "撤回撤销"]), (
            f"打手订单操作中未找到取消撤销入口；source_order_no={source_order_no}; text={modal_text[:1500]}"
        )
        for action in ["取消撤销", "撤销取消", "撤回撤销"]:
            if action in modal_text:
                self.click_player_order_modal_action(frame, action)
                break
        self.page.wait_for_timeout(1000)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        submitted = self.click_overlay_action(
            frame,
            ["确认取消", "取消撤销", "确认提交", "提交", "确定", "确认"],
        )
        if not submitted:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_player_my_orders(frame, source_order_no, order_title)
        final_row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        assert any(key in final_row_text for key in ["代练中", "服务中", "已接单"]) and "撤销中" not in final_row_text, (
            f"打手取消撤销后未恢复代练中/服务中；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def submit_player_delivery_for_acceptance(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"打手应用-我的订单未找到待交付订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        if "待验收" in self.extract_frame_order_row_text(frame, source_order_no, order_title):
            return self.search_player_my_orders(frame, source_order_no, order_title)

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        if "确认上号" in modal_text:
            self.confirm_player_login_on_order(frame)
            self.close_player_order_modal_if_present(frame)
            self.wait_player_order_actions_change(frame, source_order_no, order_title, absent="确认上号")

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        if "上传首图" in modal_text:
            self.click_player_order_modal_action(frame, "上传首图")
            self.page.wait_for_timeout(1000)
            self.fill_modal_textarea_if_present(frame, "E2E首图说明")
            self.confirm_delivery_upload_with_retry(frame, "确认上传")

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        assert "提交验收" in modal_text, (
            f"打手订单操作中未找到提交验收；source_order_no={source_order_no}; text={modal_text[:1500]}"
        )
        self.click_player_order_modal_action(frame, "提交验收")
        self.page.wait_for_timeout(1000)
        self.fill_modal_textarea_if_present(frame, f"E2E提交验收 {source_order_no}")
        self.confirm_delivery_upload_with_retry(frame, "确认提交")
        self.page.wait_for_timeout(4000)
        final_text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert "待验收" in final_text, (
            f"打手提交验收后订单未变为待验收；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def cancel_player_delivery_acceptance(self, source_order_no: str, order_title: str, reason: str) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        before_text = self.search_player_my_orders(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"打手应用-我的订单未找到待取消验收订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        if "待验收" not in row_text:
            assert any(key in row_text for key in ["代练中", "服务中", "已接单"]), (
                f"S-FLOW-041 前置状态既不是待验收，也未恢复代练中；"
                f"source_order_no={source_order_no}; row_text={row_text[:1500]}; text={before_text[:1500]}"
            )
            return before_text

        self.open_player_order_operation(frame, source_order_no, order_title)
        modal_text = frame.locator("body").inner_text(timeout=5000)
        action_texts = ["取消验收", "撤回验收", "取消申请验收", "撤销验收"]
        assert any(key in modal_text for key in action_texts), (
            f"打手订单操作中未找到取消验收入口；source_order_no={source_order_no}; text={modal_text[:1500]}"
        )
        for action in action_texts:
            if action in modal_text:
                self.click_player_order_modal_action(frame, action)
                break
        self.page.wait_for_timeout(1000)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        submitted = self.click_overlay_action(
            frame,
            ["确认取消", "取消验收", "确认提交", "提交", "确定", "确认"],
        )
        if not submitted:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_player_my_orders(frame, source_order_no, order_title)
        final_row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        assert any(key in final_row_text for key in ["代练中", "服务中", "已接单"]) and "待验收" not in final_row_text, (
            f"打手取消验收后未恢复代练中/服务中；source_order_no={source_order_no}; text={final_text[:1500]}"
        )
        return final_text

    def open_player_order_operation(self, frame: Frame, source_order_no: str, order_title: str) -> None:
        clicked = self.click_row_action(frame, source_order_no, order_title, ["操 作", "操作"])
        assert clicked, (
            f"打手应用-我的订单找到订单但未点到操作；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(1000)

    def wait_player_order_actions_change(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        *,
        absent: str,
    ) -> None:
        last_text = ""
        for _ in range(6):
            self.page.wait_for_timeout(1200)
            self.open_player_order_operation(frame, source_order_no, order_title)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if absent not in last_text:
                return
            self.close_player_order_modal_if_present(frame)
        raise AssertionError(
            f"打手订单操作后动作仍未变化，仍存在 {absent}；"
            f"source_order_no={source_order_no}; text={last_text[:1500]}"
        )

    def close_player_order_modal_if_present(self, frame: Frame) -> None:
        frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const close = root.querySelector('.ant-modal-close');
                if (close) {
                  close.click();
                  return;
                }
                const nodes = Array.from(root.querySelectorAll('button, span, div, a'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const cancel = nodes.find(item => item.text === '取 消' || item.text === '取消' || item.text === '关闭');
                cancel?.el.click();
            }"""
        )
        self.page.wait_for_timeout(800)

    def click_player_order_modal_action(self, frame: Frame, text: str) -> None:
        clicked_by_locator = False
        for locator in [
            frame.locator(".ant-modal").last.get_by_role("button", name=text).first,
            frame.locator(".ant-modal button", has_text=text).first,
            frame.locator(".ant-modal").last.get_by_text(text, exact=True).first,
        ]:
            try:
                if locator.count() > 0 and locator.is_visible(timeout=1000):
                    locator.click(timeout=5000)
                    clicked_by_locator = True
                    break
            except Exception:
                continue

        clicked = clicked_by_locator or frame.evaluate(
            """text => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const nodes = Array.from(root.querySelectorAll('button, span, div, a'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                let clickable = target.el;
                for (let node = target.el; node && node !== root; node = node.parentElement) {
                  const style = window.getComputedStyle(node);
                  const className = String(node.className || '');
                  if (
                    node.tagName === 'BUTTON' ||
                    node.tagName === 'A' ||
                    node.getAttribute('role') === 'button' ||
                    style.cursor === 'pointer' ||
                    className.includes('button') ||
                    className.includes('action') ||
                    className.includes('operation')
                  ) {
                    clickable = node;
                    break;
                  }
                }
                const r = target.r;
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }""",
            text,
        )
        assert clicked, (
            f"订单操作弹框未找到动作：{text}；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(800)
        if text.startswith("确认") and text not in {"确认上号", "确认上传", "确认提交"}:
            self.confirm_open_dialog(frame)

    def confirm_player_login_on_order(self, frame: Frame) -> None:
        clicked_action = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const body = root.querySelector('.ant-modal-body') || root;
                const buttons = Array.from(body.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = buttons.find(item => item.text === '确认上号') ||
                  buttons.find(item => item.text.includes('确认上号'));
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        assert clicked_action, (
            "订单操作弹框未找到操作区的确认上号按钮；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(800)
        clicked_footer = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const footer = root.querySelector('.ant-modal-footer') || root;
                const buttons = Array.from(footer.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = buttons.find(item => item.text === '确认上号') ||
                  buttons.find(item => item.text.includes('确认上号'));
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        assert clicked_footer, (
            "点击第一次确认上号后，未找到弹框右下角新增的确认上号按钮；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(1500)

    def click_player_order_action_by_mouse(self, frame: Frame, text: str) -> bool:
        box = frame.evaluate(
            """text => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const node = Array.from(root.querySelectorAll('button, span, div, a'))
                  .map(el => ({ text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .find(item => item.text === text || item.text.split(/\\s+/).includes(text));
                return node ? { x: node.r.x, y: node.r.y, width: node.r.width, height: node.r.height } : null;
            }""",
            text,
        )
        if not box:
            return False
        frame_box = frame.frame_element().bounding_box()
        if not frame_box:
            return False
        self.page.mouse.click(
            frame_box["x"] + box["x"] + box["width"] / 2,
            frame_box["y"] + box["y"] + box["height"] / 2,
        )
        self.page.wait_for_timeout(800)
        return True

    def extract_frame_order_row_text(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        return str(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle }) => {
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'));
                    const row = rows.find(item => {
                      const text = item.innerText || item.textContent || '';
                      return text.includes(sourceOrderNo) || text.includes(orderTitle);
                    });
                    return row ? (row.innerText || row.textContent || '') : document.body.innerText;
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title},
            )
        )

    def fill_modal_textarea_if_present(self, frame: Frame, value: str) -> None:
        filled = frame.evaluate(
            """value => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const target = Array.from(root.querySelectorAll('textarea, input'))
                  .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .find(item => item.el.tagName === 'TEXTAREA' || item.placeholder.includes('说明') || item.placeholder.includes('描述'));
                if (!target) return false;
                const proto = target.el.tagName === 'TEXTAREA'
                  ? window.HTMLTextAreaElement.prototype
                  : window.HTMLInputElement.prototype;
                const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                setter.call(target.el, value);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                return true;
            }""",
            value,
        )
        self.page.wait_for_timeout(500)

    def upload_player_delivery_image(self, frame: Frame) -> None:
        attachment = self.delivery_attachment_path()
        file_inputs = frame.locator(".ant-modal input[type='file']")
        assert file_inputs.count() > 0, (
            "上传首图弹框未找到图片上传控件；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        for index in range(file_inputs.count()):
            file_inputs.nth(index).set_input_files(str(attachment))
            self.page.wait_for_timeout(1200)
            if self.modal_has_upload_item(frame):
                break
        if not self.modal_has_upload_item(frame):
            upload_text = frame.locator(".ant-modal").last.get_by_text("上传图片").last
            try:
                if upload_text.count() > 0 and upload_text.is_visible(timeout=1000):
                    with self.page.expect_file_chooser(timeout=3000) as chooser_info:
                        upload_text.click(timeout=3000)
                    chooser_info.value.set_files(str(attachment))
                    self.page.wait_for_timeout(1200)
            except Exception:
                pass
        self.wait_modal_upload_complete(frame, timeout_ms=8000)

    def modal_has_upload_item(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    if (!root) return false;
                    const hasItem = Array.from(root.querySelectorAll(
                      '.ant-upload-list-item, [class*="upload-list-item"], .ant-upload-list img, [class*="upload-list"] img'
                    )).some(el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    });
                    if (hasItem) return true;
                    return Array.from(root.querySelectorAll('button')).some(el => {
                      const text = (el.innerText || el.textContent || '').trim();
                      const r = el.getBoundingClientRect();
                      return text.includes('确认') && !el.disabled && r.width > 0 && r.height > 0;
                    });
                }"""
            )
        )

    def wait_modal_upload_complete(self, frame: Frame, timeout_ms: int = 15000) -> bool:
        for _ in range(max(1, timeout_ms // 500)):
            complete = frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    if (!root) return false;
                    const items = Array.from(root.querySelectorAll(
                      '.ant-upload-list-item, [class*="upload-list-item"]'
                    )).filter(el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    });
                    if (!items.length) return false;
                    const uploading = items.some(el => String(el.className || '').includes('uploading'));
                    const errors = items.some(el => String(el.className || '').includes('error'));
                    if (uploading || errors) return false;
                    const item = items[items.length - 1];
                    const hasUploadedNode = !!item.querySelector('img, .ant-upload-list-item-thumbnail, [class*="thumbnail"], [class*="file"]');
                    const confirmEnabled = Array.from(root.querySelectorAll('button')).some(el => {
                      const text = (el.innerText || el.textContent || '').trim();
                      const r = el.getBoundingClientRect();
                      return text.includes('确认') && !el.disabled && r.width > 0 && r.height > 0;
                    });
                    return hasUploadedNode || confirmEnabled;
                }"""
            )
            if complete:
                return True
            self.page.wait_for_timeout(500)
        return False

    def confirm_delivery_upload_with_retry(self, frame: Frame, confirm_text: str, max_attempts: int = 4) -> None:
        last_text = ""
        for attempt in range(max_attempts):
            self.upload_player_delivery_image(frame)
            self.click_player_order_modal_action(frame, confirm_text)
            if self.wait_player_upload_modal_closed(frame, confirm_text, timeout_ms=6000, raise_on_timeout=False):
                return
            last_text = frame.locator("body").inner_text(timeout=5000)
            self.remove_failed_or_pending_upload_items(frame)
            self.page.wait_for_timeout(800 + attempt * 400)
        raise AssertionError(
            f"多次上传图片后点击{confirm_text}仍未成功，弹框未关闭；"
            f"text={last_text[:1500]}"
        )

    def remove_failed_or_pending_upload_items(self, frame: Frame) -> None:
        frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const nodes = Array.from(root.querySelectorAll(
                  '.ant-upload-list-item .anticon-delete, .ant-upload-list-item .anticon-close, ' +
                  '.ant-upload-list-item [aria-label*="delete"], .ant-upload-list-item [aria-label*="close"], ' +
                  '.ant-upload-list-item [title*="删除"], .ant-upload-list-item [title*="移除"], ' +
                  '[class*="upload-list-item"] [class*="delete"], [class*="upload-list-item"] [class*="remove"]'
                )).filter(el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                });
                for (const node of nodes) {
                  const button = node.closest('button, a, span, div') || node;
                  button.click();
                }
            }"""
        )

    def wait_player_upload_modal_closed(
        self,
        frame: Frame,
        confirm_text: str = "确认上传",
        timeout_ms: int = 10000,
        *,
        raise_on_timeout: bool = True,
    ) -> bool:
        for _ in range(max(1, timeout_ms // 500)):
            modal_text = frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    return root ? (root.innerText || root.textContent || '') : '';
                }"""
            )
            if confirm_text not in str(modal_text):
                return True
            self.page.wait_for_timeout(500)
        if raise_on_timeout:
            raise AssertionError(
                f"点击{confirm_text}后弹框未关闭，可能图片仍在上传或上传失败；"
                f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
        return False

    def delivery_attachment_path(self) -> Path:
        attachment = Path("runtime/delivery-placeholder.png")
        attachment.parent.mkdir(parents=True, exist_ok=True)
        try:
            from PIL import Image, ImageDraw

            image = Image.new("RGB", (320, 180), color=(245, 248, 255))
            draw = ImageDraw.Draw(image)
            draw.rectangle((16, 16, 304, 164), outline=(42, 94, 210), width=4)
            draw.text((32, 72), "E2E DELIVERY IMAGE", fill=(42, 94, 210))
            image.save(attachment, format="PNG")
        except Exception:
            if not attachment.exists():
                attachment.write_bytes(
                    bytes.fromhex(
                        "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753"
                        "de0000000c49444154789c6360f8cf00000301010118dd8db00000000049454e44ae426082"
                    )
                )
        return attachment

    def open_player_my_orders(self, source_order_no: str = "") -> Frame:
        last_text = ""
        for attempt in range(4):
            self.goto(WANYOUJI_CONSOLE_URL)
            self.wait_for_enterprise_console_shell()
            self.expand_player_app_menu()
            clicked = False
            try:
                self.click_sidebar_path(
                    "/orderManagement/myOrder",
                    app_hint="打手应用",
                    fallback_texts=["我的订单"],
                )
                clicked = True
            except Exception:
                clicked = self.click_sidebar_leaf_strict("我的订单")
            if clicked:
                self.page.wait_for_timeout(4000)
                frame = self.player_my_orders_frame_if_ready()
                if frame is not None:
                    return frame
            last_text = self.page.locator("body").inner_text(timeout=5000)
            self.page.reload(wait_until="domcontentloaded")
            self.page.wait_for_timeout(1500 + attempt * 500)
        raise AssertionError(
            "未找到打手应用-我的订单入口；打手抢单后左侧菜单未出现我的订单，"
            f"请检查菜单是否需要刷新或权限是否延迟生效；text={last_text[:1200]}"
        )

    def expand_player_app_menu(self) -> None:
        for _ in range(3):
            text = self.page.locator("body").inner_text(timeout=5000)
            if "我的订单" in text:
                return
            self.click_sidebar_parent_strict("打手应用")
            self.page.wait_for_timeout(1000)
        text = self.page.locator("body").inner_text(timeout=5000)
        assert "我的订单" in text, f"打手应用菜单未展开出我的订单；text={text[:1200]}"

    def click_sidebar_parent_strict(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const rows = Array.from(document.querySelectorAll('taro-view-core, div, span'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 210 && item.r.y > 40)
                  .filter(item => item.r.width > 20 && item.r.width < 210 && item.r.height > 20)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = rows[0];
                if (!target) return false;
                target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width - 16, clientY: target.r.y + target.r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width - 16, clientY: target.r.y + target.r.height / 2 }));
                target.el.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def click_sidebar_leaf_strict(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const rows = Array.from(document.querySelectorAll('taro-view-core, div, span'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 210 && item.r.y > 40)
                  .filter(item => item.r.width > 20 && item.r.width < 210 && item.r.height > 14)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return a.r.y - b.r.y || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = rows[0];
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_sidebar_menu_item_exact(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const items = Array.from(document.querySelectorAll('li.ant-menu-item, [role="menuitem"], .ant-menu-item'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 230 && item.r.y > 40)
                  .filter(item => item.r.width > 40 && item.r.height > 18)
                  .filter(item => item.value === text || item.value.replace(/\\s+/g, '') === text)
                  .sort((a, b) => a.r.y - b.r.y);
                const target = items[0];
                if (!target) return false;
                target.el.scrollIntoView({ block: 'center', inline: 'nearest' });
                const r = target.el.getBoundingClientRect();
                target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                target.el.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def player_my_orders_frame_if_ready(self) -> Frame | None:
        for frame in self.wanyouji_business_frames():
            try:
                text = frame.locator("body").inner_text(timeout=3000)
            except Exception:
                continue
            if "orderManagement/order" in frame.url:
                return frame
            if any(key in text for key in ["渠道单号", "打手单号", "代练中", "待验收", "订单来源"]):
                return frame
        return None

    def open_player_my_orders_by_route(self, source_order_no: str = "") -> Frame | None:
        for route in [
            "https://test-booster.lianlianwz.com/orderManagement/order",
            "https://test-booster.lianlianwz.com/orderManagement/myOrder",
        ]:
            try:
                self.page.evaluate(
                    """url => {
                        const frame = Array.from(document.querySelectorAll('iframe'))
                          .find(item => (item.src || '').includes('test-booster'));
                        if (frame) {
                          frame.src = url;
                          return;
                        }
                        window.location.href = url;
                    }""",
                    route,
                )
                self.page.wait_for_timeout(3500)
                frame = self.best_wanyouji_frame(["渠道单号", "打手单号", "代练中", "订单来源"])
                if source_order_no:
                    self.search_order_if_possible(frame, source_order_no)
                    text = frame.locator("body").inner_text(timeout=5000)
                    if source_order_no not in text:
                        continue
                return frame
            except Exception:
                continue
        return None

    def click_player_sidebar_leaf(self, text: str) -> bool:
        try:
            self.click_sidebar_text(text)
            return True
        except AssertionError:
            pass
        self.scroll_sidebar_if_possible()
        if self.click_visible_text_anywhere(text):
            return True
        return False

    def scroll_sidebar_if_possible(self) -> None:
        self.page.evaluate(
            """() => {
                const candidates = Array.from(document.querySelectorAll('div, taro-view-core'))
                  .map(el => ({ el, r: el.getBoundingClientRect(), text: el.innerText || el.textContent || '' }))
                  .filter(item => item.r.x >= 0 && item.r.x < 260 && item.r.height > 120)
                  .filter(item => item.text.includes('打手应用') || item.text.includes('抢单大厅'));
                const target = candidates.sort((a, b) => b.r.height - a.r.height)[0]?.el;
                if (target) target.scrollTop = target.scrollTop + 240;
            }"""
        )
        self.page.wait_for_timeout(500)

    def fallback_player_order_frame(self, source_order_no: str) -> Frame | None:
        for frame in self.wanyouji_business_frames():
            try:
                if source_order_no:
                    self.search_order_if_possible(frame, source_order_no)
                text = frame.locator("body").inner_text(timeout=3000)
            except Exception:
                continue
            if source_order_no and source_order_no in text:
                return frame
            if any(key in text for key in ["我的订单", "订单标题", "来源单号", "订单编号"]):
                return frame
        return None

    def search_player_my_orders(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        self.reset_search_if_possible(frame)
        self.search_order_if_possible(frame, source_order_no)
        return frame.locator("body").inner_text(timeout=5000)

    def open_workshop_order_list(self) -> Frame:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        self.open_workshop_order_list_menu_once()
        self.page.wait_for_timeout(3000)
        self.retry_if_app_load_timeout()
        try:
            frame = self.best_wanyouji_frame(["来源单号", "子单状态", "订单编号", "订单列表"])
        except AssertionError:
            self.force_wanyouji_business_frame_url(WANYOUJI_WORKSHOP_ORDER_LIST_URL)
            self.page.wait_for_timeout(3500)
            frame = self.best_wanyouji_frame(["来源单号", "子单状态", "订单编号", "订单列表"])
        text = frame.locator("body").inner_text(timeout=5000)
        assert self.workshop_order_list_is_ready(text), (
            f"未进入工作室履约应用-订单列表；text={text[:1500]}; "
            f"page_text={self.page.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        return frame

    def force_wanyouji_business_frame_url(self, url: str) -> None:
        for frame in self.wanyouji_business_frames():
            try:
                frame.evaluate("url => { window.location.href = url; }", url)
                return
            except Exception:
                continue
        self.page.evaluate(
            """url => {
                const frame = Array.from(document.querySelectorAll('iframe'))
                  .find(item => /test-wshop|test-orbit|test-booster|test-ledger/.test(item.src || ''));
                if (frame) {
                  frame.src = url;
                  return;
                }
                window.location.href = url;
            }""",
            url,
        )

    def ensure_auto_accept_assignment(self, merchant_name: str, enabled: bool) -> str:
        frame = self.open_auto_accept_assignment_config()
        self.search_auto_accept_assignment(frame, merchant_name)
        row_text = self.auto_accept_assignment_row_text(frame, merchant_name)
        if self.auto_accept_assignment_enabled(frame, merchant_name) == enabled:
            return row_text

        action_texts = ["开启自动接单", "开启智能接单", "开启接单", "启用"] if enabled else [
            "关闭自动接单",
            "关闭智能接单",
            "关闭接单",
            "停用",
        ]
        clicked_switch = self.click_auto_accept_assignment_switch(frame, merchant_name)
        clicked = clicked_switch or self.click_auto_accept_assignment_action(
            frame,
            merchant_name,
            action_texts,
        )
        assert clicked, (
            f"智能接单配置列表未找到切换操作；merchant={merchant_name}; expected_enabled={enabled}; "
            f"row_text={row_text}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if clicked_switch:
            self.confirm_dialog_if_visible(frame)
        else:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(2500)
        self.search_auto_accept_assignment(frame, merchant_name)
        row_text = self.auto_accept_assignment_row_text(frame, merchant_name)
        assert self.auto_accept_assignment_enabled(frame, merchant_name) == enabled, (
            f"智能接单开关切换后不符合预期；merchant={merchant_name}; expected={enabled}; row_text={row_text}"
        )
        return row_text

    def open_auto_accept_assignment_config(self) -> Frame:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        # verified: 2026-07-02 member screenshot, 丸友集 -> 工作室基础应用 -> 基础配置 -> 智能接单配置
        verified_paths = [
            ("工作室基础应用", "基础配置", "智能接单配置"),
        ]
        # unverified fallback: keep only as diagnostics-friendly compatibility, do not treat these as validated paths.
        fallback_paths = [
            ("工作室履约应用", "智能接单配置"),
            ("工作室履约应用", "自动接单配置"),
            ("工作室履约应用", "接单配置"),
            ("工作室履约应用", "接单设置"),
            ("工作室管理", "智能接单配置"),
            ("工作室管理", "自动接单配置"),
            ("工作室管理", "接单配置"),
        ]
        for menu_path in verified_paths:
            if self.open_sidebar_menu_path(list(menu_path)):
                self.page.wait_for_timeout(2500)
                self.retry_if_app_load_timeout()
                return self.best_wanyouji_frame(["自动接单", "智能接单", "智能派单", "订单列表", "商家", "工作室"])
        for menu_path in fallback_paths:
            if self.open_sidebar_menu_path(list(menu_path)):
                print(f"[E2E][WARN] 命中未验证智能接单配置菜单兜底：{' > '.join(menu_path)}")
                self.page.wait_for_timeout(2500)
                self.retry_if_app_load_timeout()
                return self.best_wanyouji_frame(["自动接单", "智能接单", "智能派单", "订单列表", "商家", "工作室"])

        page_text = self.page.locator("body").inner_text(timeout=5000)
        sidebar_text = self.left_sidebar_snapshot()
        raise AssertionError(f"未找到丸友集智能接单配置入口；sidebar={sidebar_text[:1500]}; text={page_text[:1500]}")

    def left_sidebar_snapshot(self) -> str:
        try:
            return str(
                self.page.evaluate(
                    """() => Array.from(document.querySelectorAll('taro-view-core, div, span, a, button'))
                      .map(el => ({ text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.text && item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.r.x >= 0 && item.r.x < 260 && item.r.y > 40)
                      .map(item => item.text)
                      .join('\\n')"""
                )
            )
        except Exception as exc:
            return f"<sidebar snapshot failed: {exc}>"

    def open_sidebar_menu_path(self, texts: list[str]) -> bool:
        for index, text in enumerate(texts):
            right_edge = index < len(texts) - 1
            clicked = False
            try:
                self.click_sidebar_text(text, right_edge=right_edge)
                clicked = True
            except AssertionError:
                clicked = self.click_sidebar_visible_text(text, right_edge=right_edge) or self.click_left_sidebar_text_contains(
                    text,
                    right_edge=right_edge,
                )
            if not clicked:
                clicked = (
                    self.click_sidebar_menu_item_exact(text)
                    or self.click_sidebar_leaf_precise(text)
                    or self.click_sidebar_text_scrolled(text, right_edge=right_edge)
                )
            if not clicked:
                return False
            self.page.wait_for_timeout(700)
        return True

    def search_auto_accept_assignment(self, frame: Frame, merchant_name: str) -> None:
        last_text = ""
        for attempt in range(4):
            if attempt:
                self.click_optional_text(frame, "重 置") or self.click_optional_text(frame, "重置")
                self.page.wait_for_timeout(800)
            filled = False
            for label in ["商家名称", "上游商家", "店铺名称", "工作室名称", "供应链名称", "关键字"]:
                if self.fill_input_near_label(frame, label, merchant_name):
                    filled = True
                    break
            if not filled:
                inputs = frame.locator("input:visible")
                if inputs.count():
                    inputs.first.fill(merchant_name)
                    filled = True
            if filled and not (self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")):
                frame.locator("input:visible").first.press("Enter")
            self.page.wait_for_timeout(1800 + attempt * 600)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if merchant_name in last_text:
                return
        raise AssertionError(f"智能接单配置搜索后未找到商家：{merchant_name}；text={last_text[:1500]}")

    def auto_accept_assignment_row_text(self, frame: Frame, merchant_name: str) -> str:
        for _ in range(4):
            row_text = str(
                frame.evaluate(
                    """merchantName => {
                        const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [class*="table"] [class*="row"]'))
                          .map(row => ({ text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                          .filter(item => item.r.width > 0 && item.r.height > 0)
                          .filter(item => item.text.includes(merchantName));
                        return rows[0]?.text || '';
                    }""",
                    merchant_name,
                )
            )
            if row_text:
                return row_text
            self.page.wait_for_timeout(1000)
        raise AssertionError(
            f"智能接单配置列表未找到商家：{merchant_name}；text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )

    @staticmethod
    def auto_accept_assignment_enabled_from_row(row_text: str) -> bool:
        compact = re.sub(r"\s+", "", row_text or "")
        if any(key in compact for key in ["关闭自动接单", "关闭智能接单", "关闭接单", "已开启", "开启中"]):
            return True
        if any(key in compact for key in ["开启自动接单", "开启智能接单", "开启接单", "已关闭", "未开启"]):
            return False
        lines = [line.strip() for line in row_text.splitlines() if line.strip()]
        for index, line in enumerate(lines):
            if any(key in line for key in ["自动接单", "智能接单", "是否开启"]):
                nearby = "".join(lines[index : index + 3])
                if "是" in nearby or "开启" in nearby:
                    return True
                if "否" in nearby or "关闭" in nearby:
                    return False
        return False

    def auto_accept_assignment_enabled(self, frame: Frame, merchant_name: str) -> bool:
        value = frame.evaluate(
            """merchantName => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [class*="table"] [class*="row"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(merchantName));
                const row = rows[0]?.row;
                if (!row) return null;
                const findAssignmentCell = row => {
                  const table = row.closest('table');
                  if (!table) return null;
                  const headers = Array.from(table.querySelectorAll('thead th'))
                    .map((th, index) => ({ index, text: (th.innerText || th.textContent || '').trim() }));
                  const header = headers.find(item => item.text.includes('自动接指派单')) ||
                    headers.find(item => item.text.includes('接指派单')) ||
                    headers.find(item => item.text.includes('自动接单'));
                  if (!header) return null;
                  return Array.from(row.querySelectorAll('td'))[header.index] || null;
                };
                const switchCandidates = root => Array.from(root.querySelectorAll('.ant-switch, [role="switch"], input[type="checkbox"], button'))
                  .filter(visible)
                  .filter(el => {
                    const cls = String(el.className || '');
                    return cls.includes('switch') || el.getAttribute('role') === 'switch' || el.tagName === 'INPUT';
                  });
                const cell = findAssignmentCell(row) || row;
                const switches = switchCandidates(cell);
                const sw = switches[0] || switchCandidates(row)[0];
                if (!sw) return null;
                const cls = String(sw.className || '');
                if (cls.includes('ant-switch-checked') || cls.includes('is-checked')) return true;
                const aria = sw.getAttribute('aria-checked');
                if (aria === 'true') return true;
                if (aria === 'false') return false;
                if (sw.tagName === 'INPUT') return Boolean(sw.checked);
                const checked = sw.querySelector('.ant-switch-checked, [aria-checked="true"], input:checked');
                if (checked) return true;
                const style = window.getComputedStyle(sw);
                const color = style.backgroundColor || style.borderColor || '';
                const parts = color.match(/\\d+(?:\\.\\d+)?/g)?.map(Number) || [];
                if (parts.length >= 3) {
                  const [r, g, b] = parts;
                  if (g > r + 20 && g > b + 20 && g >= 120) return true;
                  if (Math.abs(r - g) < 25 && Math.abs(g - b) < 25) return false;
                }
                return false;
            }""",
            merchant_name,
        )
        assert value is not None, (
            f"智能接单配置未找到商家自动接指派单开关；merchant={merchant_name}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        return bool(value)

    def click_auto_accept_assignment_switch(self, frame: Frame, merchant_name: str) -> bool:
        clicked = frame.evaluate(
            """merchantName => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [class*="table"] [class*="row"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(merchantName));
                const row = rows[0]?.row;
                if (!row) return false;
                const findAssignmentCell = row => {
                  const table = row.closest('table');
                  if (!table) return null;
                  const headers = Array.from(table.querySelectorAll('thead th'))
                    .map((th, index) => ({ index, text: (th.innerText || th.textContent || '').trim() }));
                  const header = headers.find(item => item.text.includes('自动接指派单')) ||
                    headers.find(item => item.text.includes('接指派单')) ||
                    headers.find(item => item.text.includes('自动接单'));
                  if (!header) return null;
                  return Array.from(row.querySelectorAll('td'))[header.index] || null;
                };
                const switchCandidates = root => Array.from(root.querySelectorAll('.ant-switch, [role="switch"], input[type="checkbox"], button'))
                  .map(el => ({ el, r: el.getBoundingClientRect(), cls: String(el.className || '') }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.cls.includes('switch') || item.el.getAttribute('role') === 'switch' || item.el.tagName === 'INPUT');
                const cell = findAssignmentCell(row) || row;
                const target = (switchCandidates(cell)[0] || switchCandidates(row)[0])?.el;
                if (!target) return false;
                const clickable = target.closest('button, [role="switch"], label') || target;
                const r = clickable.getBoundingClientRect();
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }""",
            merchant_name,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def confirm_dialog_if_visible(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                if (!roots.length) return false;
                const root = roots[roots.length - 1];
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const positiveTexts = ['确认', '确定', '是'];
                const target = buttons.find(item =>
                  positiveTexts.some(text => item.text === text || item.text.includes(text))
                );
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                clickable.click();
                return true;
            }"""
        )
        if clicked:
            self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_auto_accept_assignment_action(self, frame: Frame, merchant_name: str, action_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """({ merchantName, actionTexts }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [class*="table"] [class*="row"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(merchantName));
                const row = rows[0]?.row;
                if (!row) return false;
                const nodes = Array.from(row.querySelectorAll('button, a, span, [role="button"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => visible(item.el));
                for (const actionText of actionTexts) {
                  const target = nodes.find(item => item.text === actionText) ||
                    nodes.find(item => item.text.includes(actionText));
                  if (!target) continue;
                  const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                  const r = target.r;
                  clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.click();
                  return true;
                }
                return false;
            }""",
            {"merchantName": merchant_name, "actionTexts": action_texts},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def ensure_auto_acceptance_private_order_rules(
        self,
        *,
        merchant_name: str,
        game_name: str,
        service_project: str,
        max_private_order_count: str = "800",
    ) -> str:
        self.ensure_auto_accept_assignment(merchant_name, enabled=True)
        frame = self.open_auto_accept_assignment_config()
        self.search_auto_accept_assignment(frame, merchant_name)
        row_text = self.auto_accept_assignment_row_text(frame, merchant_name)
        assert self.auto_accept_assignment_enabled(frame, merchant_name), (
            f"智能接单配置商家自动接指派单未开启；merchant={merchant_name}; row_text={row_text}"
        )

        clicked = self.click_workshop_acceptance_profile_edit(frame)
        assert clicked, (
            f"智能接单配置页未找到上方【编辑画像】入口；merchant={merchant_name}; row_text={row_text}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(1500)

        editor_text = self.visible_acceptance_profile_editor_text(frame)
        assert editor_text, (
            f"点击【编辑画像】后未打开画像编辑弹窗/抽屉；merchant={merchant_name}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )

        self.ensure_auto_acceptance_combo_value(frame, ["接单游戏", "游戏"], game_name)
        self.ensure_auto_acceptance_combo_value(frame, ["服务项目", "接单服务项目"], service_project)
        private_count_filled = self.fill_auto_acceptance_config_input(
            frame,
            ["工作室每日私单最大接单量", "每日私单最大接单量", "私单最大接单量", "最大接单量"],
            max_private_order_count,
        )
        assert private_count_filled, (
            f"智能接单配置弹窗未找到每日私单最大接单量输入框；merchant={merchant_name}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )

        saved = self.click_acceptance_profile_editor_button(frame, ["保 存", "保存", "确 定", "确定", "提交"])
        assert saved, f"智能接单画像编辑弹窗/抽屉未找到保存/确定按钮；panel={self.visible_acceptance_profile_editor_text(frame)[:1500]}"
        self.page.wait_for_timeout(2500)

        self.search_auto_accept_assignment(frame, merchant_name)
        final_row_text = self.auto_accept_assignment_row_text(frame, merchant_name)
        assert self.auto_accept_assignment_enabled(frame, merchant_name), (
            f"智能接单配置保存后自动接指派单未保持开启；merchant={merchant_name}; row_text={final_row_text}"
        )
        return final_row_text

    def click_workshop_acceptance_profile_edit(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], span, div'))
                  .map(el => ({
                    el,
                    clickable: el.closest('button, a, [role="button"]') || el,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === '编辑画像' || item.text.includes('编辑画像'));
                const target = nodes.sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                if (!target) return false;
                target.clickable.click();
                return true;
            }"""
        )
        if clicked:
            self.page.wait_for_timeout(1000)
        return bool(clicked)

    def visible_acceptance_profile_editor_text(self, frame: Frame) -> str:
        script = """() => {
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
              .filter(el => {
                const r = el.getBoundingClientRect();
                return r.width > 0 && r.height > 0;
              });
            const root = roots.pop();
            return root ? (root.innerText || root.textContent || '') : '';
        }"""
        text = str(frame.evaluate(script))
        if text:
            return text
        return str(self.page.evaluate(script))

    def click_acceptance_profile_editor_button(self, frame: Frame, texts: list[str]) -> bool:
        script = """texts => {
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, .ant-popover, .ant-popconfirm'))
              .filter(el => {
                const r = el.getBoundingClientRect();
                return r.width > 0 && r.height > 0;
              });
            const root = roots.pop();
            if (!root) return false;
            const buttons = Array.from(root.querySelectorAll('button, a, [role="button"]'))
              .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), disabled: el.disabled, r: el.getBoundingClientRect() }))
              .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
            for (const text of texts) {
              const normalized = String(text || '').replace(/\\s+/g, '');
              const target = buttons.find(item => item.text === text) ||
                buttons.find(item => item.text.replace(/\\s+/g, '') === normalized) ||
                buttons.find(item => item.text.includes(text) || item.text.replace(/\\s+/g, '').includes(normalized));
              if (target) {
                target.el.click();
                return true;
              }
            }
            return false;
        }"""
        clicked = frame.evaluate(script, texts)
        if not clicked:
            clicked = self.page.evaluate(script, texts)
        if clicked:
            self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_auto_accept_assignment_switch_detail(self, frame: Frame, merchant_name: str) -> bool:
        clicked = frame.evaluate(
            """merchantName => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row, [class*="table"] [class*="row"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(merchantName));
                const row = rows[0]?.row;
                if (!row) return false;
                const actionNodes = Array.from(row.querySelectorAll('button, a, [role="button"], span, div'))
                  .map(el => ({
                    el,
                    clickable: el.closest('button, a, [role="button"]') || el,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = actionNodes.find(item => /编辑|配置|调整|设置|修改|详情/.test(item.text));
                if (!target) return false;
                target.clickable.click();
                return true;
            }""",
            merchant_name,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def ensure_auto_acceptance_combo_value(self, frame: Frame, label_texts: list[str], option_text: str) -> None:
        if not option_text:
            return
        if self.auto_acceptance_modal_contains(frame, option_text):
            return
        selected = False
        for label_text in label_texts:
            if self.select_auto_acceptance_config_option(frame, label_text, option_text):
                selected = True
                break
        assert selected or self.auto_acceptance_modal_contains(frame, option_text), (
            f"智能接单配置弹窗未能选择配置项；labels={label_texts}; option={option_text}; "
            f"panel={(self.visible_modal_text(frame) or self.visible_acceptance_profile_editor_text(frame))[:1500]}"
        )

    def auto_acceptance_modal_contains(self, frame: Frame, text: str) -> bool:
        modal_text = self.visible_modal_text(frame) or self.visible_acceptance_profile_editor_text(frame)
        return bool(text and text in modal_text)

    def fill_auto_acceptance_config_input(self, frame: Frame, label_texts: list[str], value: str) -> bool:
        filled = frame.evaluate(
            """({ labelTexts, value }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, body')).filter(visible);
                const root = roots[0] || document;
                const setValue = input => {
                  input.focus();
                  const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                  setter.call(input, value);
                  input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  input.dispatchEvent(new Event('change', { bubbles: true }));
                  input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                  return true;
                };
                const normalizedLabels = labelTexts.map(item => String(item || '').replace(/\\s+/g, ''));
                const controls = Array.from(root.querySelectorAll('input'))
                  .filter(input => visible(input) && !input.disabled && input.getAttribute('type') !== 'hidden')
                  .map(input => ({
                    input,
                    placeholder: (input.getAttribute('placeholder') || '').replace(/\\s+/g, ''),
                    text: ((input.closest('.ant-form-item, [class*="form-item"], .ant-row, tr, [class*="field"]') || root).innerText || '').replace(/\\s+/g, ''),
                  }));
                const exact = controls.find(item =>
                  normalizedLabels.some(label => item.placeholder.includes(label) || item.text.includes(label))
                );
                if (exact) return setValue(exact.input);
                const numberInput = controls.find(item => item.input.getAttribute('type') === 'number');
                if (numberInput) return setValue(numberInput.input);
                return false;
            }""",
            {"labelTexts": label_texts, "value": value},
        )
        self.page.wait_for_timeout(500)
        return bool(filled)

    def select_auto_acceptance_config_option(self, frame: Frame, label_text: str, option_text: str) -> bool:
        opened = frame.evaluate(
            """({ labelText }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, body')).filter(visible);
                const root = roots[0] || document;
                const normalizedLabel = String(labelText || '').replace(/\\s+/g, '');
                const fields = Array.from(root.querySelectorAll('.ant-form-item, [class*="form-item"], .ant-row, tr, [class*="field"], div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').replace(/\\s+/g, ''), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(normalizedLabel))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const field = fields[0]?.el;
                if (!field) return false;
                const selector = Array.from(field.querySelectorAll('.ant-select-selector, [class*="select-selector"], input'))
                  .find(visible);
                if (!selector) return false;
                const clickable = selector.closest('.ant-select, [class*="select"]') || selector;
                clickable.click();
                return true;
            }""",
            {"labelText": label_text},
        )
        if not opened:
            return False
        self.page.wait_for_timeout(800)
        clicked = frame.evaluate(
            """optionText => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const normalizedOption = String(optionText || '').replace(/\\s+/g, '');
                const options = Array.from(document.querySelectorAll(
                  '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option, .ant-cascader-menu-item, [role="option"], li, div, span'
                ))
                  .map(el => ({
                    el,
                    clickable: el.closest('.ant-select-item-option, .ant-cascader-menu-item, [role="option"], li') || el,
                    text: (el.innerText || el.textContent || '').replace(/\\s+/g, ''),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = options.find(item => item.text === normalizedOption) ||
                  options.find(item => item.text.includes(normalizedOption) || normalizedOption.includes(item.text));
                if (!target) return false;
                target.clickable.click();
                return true;
            }""",
            option_text,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def open_workshop_appeal_work_order(self) -> Frame:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        try:
            self.click_sidebar_text("工作室履约应用", right_edge=True)
        except AssertionError:
            assert self.click_sidebar_visible_text("工作室履约应用", right_edge=True) or self.click_left_sidebar_text_contains("工作室履约应用", right_edge=True), (
                f"未找到工作室履约应用菜单；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.page.wait_for_timeout(800)
        try:
            self.click_sidebar_text("申诉工单")
        except AssertionError:
            assert self.click_sidebar_visible_text("申诉工单") or self.click_left_sidebar_text_contains("申诉工单"), (
                f"未找到工作室履约应用-申诉工单入口；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.page.wait_for_timeout(3000)
        self.retry_if_app_load_timeout()
        frame = self.best_wanyouji_frame(["申诉", "处罚", "工单", "订单编号", "来源单号"])
        return frame

    def approve_penalty_from_workshop(
        self,
        source_order_no: str,
        order_title: str,
        *,
        remark: str,
        upstream_order_no: str = "",
    ) -> str:
        return self.process_penalty_from_workshop(
            source_order_no,
            order_title,
            remark=remark,
            action="approve",
            upstream_order_no=upstream_order_no,
        )

    def reject_penalty_from_workshop(
        self,
        source_order_no: str,
        order_title: str,
        *,
        remark: str,
        upstream_order_no: str = "",
    ) -> str:
        return self.process_penalty_from_workshop(
            source_order_no,
            order_title,
            remark=remark,
            action="reject",
            upstream_order_no=upstream_order_no,
        )

    def process_penalty_from_workshop(
        self,
        source_order_no: str,
        order_title: str,
        *,
        remark: str,
        action: str,
        upstream_order_no: str = "",
    ) -> str:
        frame = self.open_workshop_appeal_work_order()
        query_no = upstream_order_no or source_order_no
        text = self.search_workshop_order_list(frame, query_no, order_title)
        visible_query_prefix = query_no[:13] if len(query_no) > 13 else query_no
        assert (
            query_no in text
            or visible_query_prefix in text
            or source_order_no in text
            or order_title in text
            or self.filtered_order_list_has_single_result(text)
        ), (
            f"丸友集申诉工单未找到待处理处罚记录；upstream_order_no={query_no}; "
            f"source_order_no={source_order_no}; order_title={order_title}; text={text[:1500]}"
        )
        pending_row_text = self.table_row_text_by_keywords(frame, [query_no, source_order_no, "待处理"])
        processing_row_text = self.table_row_text_by_keywords(frame, [query_no, source_order_no, "处理中"])
        processing_modal_text = ""
        if pending_row_text:
            clicked_pending = (
                self.click_workshop_appeal_status_action(frame, query_no, "待处理", "去处理")
                or self.click_workshop_appeal_status_action(frame, query_no, "待处理", "处理")
            )
            assert clicked_pending, (
                f"丸友集申诉工单未在【待处理】处罚记录操作列找到【去处理】入口；"
                f"upstream_order_no={query_no}; text={text[:1500]}"
            )
            self.page.wait_for_timeout(1200)
            pending_modal_text = self.visible_modal_text(frame)
            clicked_process = self.click_overlay_action(frame, ["我要处理"])
            assert clicked_process, (
                f"丸友集处罚详情弹框未找到【我要处理】按钮；"
                f"upstream_order_no={query_no}; modal={pending_modal_text[:1500]}"
            )
            self.page.wait_for_timeout(1500)
            processing_modal_text = self.visible_modal_text(frame)
            processing_row_text = self.table_row_text_by_keywords(frame, [query_no, source_order_no, "处理中"])
        modal_is_processing = bool(
            processing_modal_text
            and any(key in processing_modal_text for key in ["处理处罚", "实际赔偿金额", "同意赔偿", "驳 回"])
        )
        if not modal_is_processing:
            assert processing_row_text, (
                f"丸友集申诉工单目标处罚记录未处于【处理中】；"
                f"upstream_order_no={query_no}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
            )
            clicked_processing = (
                self.click_workshop_appeal_status_action(frame, query_no, "处理中", "去处理")
                or self.click_workshop_appeal_status_action(frame, query_no, "处理中", "处理")
            )
            assert clicked_processing, (
                f"丸友集申诉工单未在【处理中】处罚记录操作列找到【去处理】入口；upstream_order_no={query_no}; text={text[:1500]}"
            )
            self.page.wait_for_timeout(1200)
        action_texts = (
            ["同意赔偿", "同意处罚", "确认赔偿", "同 意", "同意"]
            if action == "approve"
            else ["驳 回", "驳回", "拒 绝", "拒绝处罚", "拒绝"]
        )
        modal_text = processing_modal_text or self.visible_modal_text(frame)
        if modal_text:
            if action == "approve":
                self.click_modal_radio_text(frame, "同意")
            else:
                self.click_modal_radio_text(frame, "驳回")
            self.fill_modal_amount_if_present(frame, "1")
            self.fill_visible_modal_inputs_if_present(frame, remark)
            if self.visible_modal_has_file_input(frame):
                self.upload_single_modal_image(frame)
            clicked_action = self.click_overlay_action(frame, action_texts)
            assert clicked_action, (
                f"丸友集处罚详情弹框未找到处理按钮；action={action}; "
                f"upstream_order_no={query_no}; modal={modal_text[:1500]}"
            )
        else:
            clicked_detail = self.click_overlay_action(frame, action_texts)
            if not clicked_detail:
                self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(1000)
        second_modal_text = self.visible_modal_text(frame)
        if second_modal_text:
            self.fill_modal_amount_if_present(frame, "1")
            self.fill_visible_modal_inputs_if_present(frame, remark)
            if self.visible_modal_has_file_input(frame):
                self.upload_single_modal_image(frame)
            confirmed = self.click_overlay_action(frame, ["确 定", "确定", "确认", "提 交", "提交"])
            if not confirmed:
                self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_workshop_order_list(frame, query_no, order_title)
        if action == "approve":
            self.assert_workshop_penalty_result(
                frame,
                upstream_order_no=query_no,
                source_order_no=source_order_no,
                expected_status="已完成",
                expected_apply_amount="1",
                expected_penalty_amount="1",
            )
        else:
            self.assert_workshop_penalty_result(
                frame,
                upstream_order_no=query_no,
                source_order_no=source_order_no,
                expected_status="已拒绝",
                expected_apply_amount="1",
                expected_penalty_amount="0",
            )
        return final_text

    def assert_workshop_penalty_result(
        self,
        frame: Frame,
        *,
        upstream_order_no: str,
        source_order_no: str = "",
        expected_status: str,
        expected_apply_amount: str,
        expected_penalty_amount: str,
    ) -> str:
        text = frame.locator("body").inner_text(timeout=5000)
        row_text = self.table_row_text_by_keywords(
            frame,
            [upstream_order_no, source_order_no, expected_status],
        )
        assert row_text, (
            f"丸友集申诉工单未找到目标处罚结果行；upstream_order_no={upstream_order_no}; "
            f"source_order_no={source_order_no}; expected_status={expected_status}; text={text[:1500]}"
        )
        compact = re.sub(r"\s+", "", row_text)
        assert expected_status in compact, (
            f"丸友集申诉工单目标行状态不符合预期；expected={expected_status}; row={row_text[:1500]}"
        )
        expected_amounts = [f"{float(expected_apply_amount):.2f}", f"{float(expected_penalty_amount):.2f}"]
        row_amounts = self.table_row_amounts_by_keywords(frame, [upstream_order_no, source_order_no, expected_status])
        amount_pair_matches = (
            len(row_amounts) >= 3 and row_amounts[-2:] == expected_amounts
        ) or self.amount_pair_in_text(compact, expected_apply_amount, expected_penalty_amount)
        assert amount_pair_matches, (
            f"丸友集申诉工单目标行金额不符合预期；"
            f"expected_apply={expected_apply_amount}; expected_penalty={expected_penalty_amount}; "
            f"amounts={row_amounts}; row={row_text[:1500]}"
        )
        return text

    def open_penalty_recovery_entry_from_terminal_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        expected_statuses: list[str] | None = None,
        close_modal: bool = True,
    ) -> str:
        expected_statuses = expected_statuses or ["已完成", "撤销完成", "已撤销"]
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in before_text or order_title in before_text, (
            f"工作室履约应用-订单列表未找到待处罚追缴订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={before_text[:1500]}"
        )
        row_text = self.extract_order_row_text(before_text, source_order_no)
        terminal_text = row_text or before_text
        terminal_ready = any(status in terminal_text for status in expected_statuses) or any(
            marker in terminal_text for marker in ["终:", "终："]
        )
        assert terminal_ready, (
            f"处罚追缴前置不满足子单已完成/撤销完成；expected={expected_statuses}; "
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}; text={before_text[:1500]}"
        )
        clicked = self.click_workshop_order_list_action(frame, source_order_no, "处罚追缴")
        if not clicked:
            clicked = self.click_row_action_allow_revoke(
                frame,
                source_order_no,
                order_title,
                ["处罚追缴", "处罚", "追缴"],
            )
        if not clicked:
            clicked = self.open_workshop_order_detail_and_click_sub_order_penalty_recovery(
                frame,
                source_order_no,
                order_title,
            )
        assert clicked, (
            f"工作室履约应用-订单列表未找到【处罚追缴】入口；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text and any(key in modal_text for key in ["处罚追缴", "追缴单", "处罚单", "处罚", "追缴"]), (
            f"点击处罚追缴后未打开处罚/追缴弹框；source_order_no={source_order_no}; "
            f"modal={modal_text[:1500]}; page={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if close_modal:
            self.close_current_modal(frame)
        return modal_text

    def create_recovery_order_from_terminal_sub_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        reason: str,
        content: str,
        amount: str,
        hours: str = "24",
    ) -> str:
        return self.create_penalty_recovery_from_terminal_sub_order(
            source_order_no,
            order_title,
            order_type="追缴单",
            reason=reason,
            content=content,
            amount=amount,
            hours=hours,
        )

    def create_direct_penalty_from_terminal_sub_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        reason: str,
        amount: str,
    ) -> str:
        return self.create_penalty_recovery_from_terminal_sub_order(
            source_order_no,
            order_title,
            order_type="处罚单",
            reason=reason,
            content=reason,
            amount=amount,
            hours="24",
        )

    def create_penalty_recovery_from_terminal_sub_order(
        self,
        source_order_no: str,
        order_title: str,
        *,
        order_type: str,
        reason: str,
        content: str,
        amount: str,
        hours: str,
    ) -> str:
        assert order_type in {"追缴单", "处罚单"}, f"处罚追缴类型仅支持追缴单/处罚单；当前值：{order_type}"
        amount_value = str(amount).strip()
        assert amount_value.isdigit(), f"处罚/追缴金额必须填写整数数字；当前值：{amount}"
        frame = self.open_workshop_order_list()
        before_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        clicked = self.click_workshop_order_list_action(frame, source_order_no, "处罚追缴")
        if not clicked:
            clicked = self.click_row_action_allow_revoke(
                frame,
                source_order_no,
                order_title,
                ["处罚追缴", "处罚", "追缴"],
            )
        if not clicked:
            clicked = self.open_workshop_order_detail_and_click_sub_order_penalty_recovery(
                frame,
                source_order_no,
                order_title,
            )
        assert clicked, (
            f"工作室履约应用-订单列表未找到【处罚追缴】入口；source_order_no={source_order_no}; "
            f"text={before_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame)
        assert modal_text, (
            f"点击处罚追缴后未打开弹框；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.select_penalty_recovery_type(frame, order_type)
        self.fill_penalty_recovery_modal(
            frame,
            order_type=order_type,
            reason=reason,
            content=content,
            amount=amount_value,
            hours=hours,
        )
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_overlay_action(
            frame,
            ["确认创建", "创建", "确认处罚", "确认追缴", "发起处罚", "发起追缴", "提 交", "提交", "确定", "确认"],
        )
        assert submitted, (
            f"处罚追缴弹框未找到提交按钮；order_type={order_type}; source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(3500)
        after_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        modal_after = self.visible_modal_text(frame)
        form_still_open = (
            "处罚追缴" in modal_after
            and "若打手在履约过程中完成后" in modal_after
            and any(key in modal_after for key in ["配置处罚原因", "配置追缴原因", "*处罚金额", "*追缴内容"])
        )
        assert not form_still_open or any(key in modal_after for key in ["成功", "已创建", "创建成功"]), (
            f"处罚追缴提交后弹框仍未关闭且未看到提交结果；order_type={order_type}; "
            f"modal={modal_after[:1500]}"
        )
        return f"{after_text}\n{modal_after}"

    def select_penalty_recovery_type(self, frame: Frame, order_type: str) -> None:
        clicked = self.click_modal_radio_text(frame, order_type)
        if clicked:
            return
        clicked = self.click_overlay_action(frame, [order_type])
        assert clicked or order_type in self.visible_modal_text(frame), (
            f"处罚追缴弹框未找到类型选项【{order_type}】；modal={self.visible_modal_text(frame)[:1500]}"
        )

    def fill_penalty_recovery_modal(
        self,
        frame: Frame,
        *,
        order_type: str,
        reason: str,
        content: str,
        amount: str,
        hours: str,
    ) -> None:
        result = frame.evaluate(
            """({ orderType, reason, content, amount, hours }) => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return { amountCount: 0, reasonCount: 0, textCount: 0, modalText: '' };
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  el.focus();
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                const isChecked = el => {
                  const input = el.querySelector?.('input[type="checkbox"], input[type="radio"]');
                  return Boolean(
                    input?.checked ||
                    el.classList?.contains('ant-checkbox-wrapper-checked') ||
                    el.classList?.contains('ant-radio-wrapper-checked') ||
                    el.querySelector?.('.ant-checkbox-checked, .ant-radio-checked, [aria-checked="true"]')
                  );
                };
                const clickReason = () => {
                  const reasonLabel = orderType === '处罚单' ? /处罚原因|原因/ : /追缴原因|原因/;
                  const reasonRoots = Array.from(root.querySelectorAll('.ant-form-item, [class*="form-item"], [class*="field"]'))
                    .filter(el => reasonLabel.test(el.innerText || el.textContent || ''));
                  const reasonRoot = reasonRoots.find(el => /(追缴|处罚|封号|超时补偿|配置)/.test(el.innerText || el.textContent || '')) ||
                    reasonRoots[0] ||
                    root;
                  const options = Array.from(reasonRoot.querySelectorAll('label, .ant-checkbox-wrapper, .ant-radio-wrapper, button, [role="button"], span'))
                    .map(el => ({
                      el,
                      text: (el.innerText || el.textContent || '').trim(),
                      r: el.getBoundingClientRect()
                    }))
                    .filter(item => item.r.width > 0 && item.r.height > 0);
                  const primary = orderType === '处罚单'
                    ? ['接单不打', '封号', '超时', '客户差评倾向/发生补偿', '接单未安排']
                    : ['追缴', '超时补偿', '封号'];
                  const candidates = [...primary, reason].filter(Boolean);
                  for (const candidate of candidates) {
                    const target = options.find(item => item.text === candidate) ||
                      options.find(item => candidate && item.text.includes(candidate));
                    if (target) {
                      const clickable = target.el.closest('label, .ant-checkbox-wrapper, .ant-radio-wrapper, button, [role="button"]') ||
                        target.el.parentElement ||
                        target.el;
                      const r = clickable.getBoundingClientRect();
                      clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                      clickable.click();
                      return 1;
                    }
                  }
                  return 0;
                };
                const reasonCount = clickReason();
                const controls = Array.from(root.querySelectorAll('input, textarea'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({
                    el,
                    placeholder: el.getAttribute('placeholder') || '',
                    formText: el.closest('.ant-form-item, [class*="form-item"], [class*="field"]')?.innerText || '',
                    tag: el.tagName,
                    type: el.getAttribute('type') || '',
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                let amountCount = 0;
                let textCount = 0;
                for (const item of controls) {
                  const label = `${item.placeholder} ${item.formText}`;
                  if (/时限|期限|小时|天/.test(label)) {
                    setValue(item.el, hours);
                    textCount += 1;
                  } else if (/金额|扣款|赔付|价格|费用|元/.test(label) || item.type === 'number') {
                    setValue(item.el, amount);
                    amountCount += 1;
                  }
                }
                for (const item of controls) {
                  if (String(item.el.value || '').trim()) continue;
                  const label = `${item.placeholder} ${item.formText}`;
                  if (/内容|说明|要求/.test(label)) {
                    setValue(item.el, content);
                    textCount += 1;
                  } else if (/原因|理由|备注/.test(label) || item.tag === 'TEXTAREA') {
                    setValue(item.el, reason);
                    textCount += 1;
                  }
                }
                for (const item of controls) {
                  if (String(item.el.value || '').trim()) continue;
                  if (item.tag === 'TEXTAREA') {
                    setValue(item.el, content || reason);
                    textCount += 1;
                  }
                }
                return { amountCount, reasonCount, textCount, modalText: root.innerText || root.textContent || '' };
            }""",
            {"orderType": order_type, "reason": reason, "content": content, "amount": amount, "hours": hours},
        )
        self.page.wait_for_timeout(500)
        assert result and result.get("reasonCount", 0) > 0, (
            f"处罚追缴弹框未勾选追缴/处罚原因；modal={str((result or {}).get('modalText', ''))[:1500]}"
        )
        assert result and result.get("amountCount", 0) > 0, (
            f"处罚追缴弹框未找到金额输入框；modal={str((result or {}).get('modalText', ''))[:1500]}"
        )

    def open_player_my_penalties(self) -> Frame:
        last_text = ""
        for attempt in range(4):
            self.goto(WANYOUJI_CONSOLE_URL)
            self.wait_for_enterprise_console_shell()
            self.expand_player_app_menu()
            if self.click_sidebar_leaf_strict("我的处罚") or self.click_player_sidebar_leaf("我的处罚"):
                self.page.wait_for_timeout(4000)
                frame = self.player_my_penalties_frame_if_ready()
                if frame is not None:
                    return frame
            last_text = self.page.locator("body").inner_text(timeout=5000)
            self.page.reload(wait_until="domcontentloaded")
            self.page.wait_for_timeout(1500 + attempt * 500)
        raise AssertionError(f"未找到打手应用-我的处罚入口；text={last_text[:1200]}")

    def player_my_penalties_frame_if_ready(self) -> Frame | None:
        for frame in self.wanyouji_business_frames():
            try:
                text = frame.locator("body").inner_text(timeout=3000)
            except Exception:
                continue
            if any(key in text for key in ["我的处罚", "追缴单", "罚单", "处罚金额", "追缴金额", "处罚"]):
                return frame
        return None

    def search_player_my_penalties(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        last_text = ""
        for keyword in [source_order_no, order_title]:
            self.reset_search_if_possible(frame)
            self.search_order_if_possible(frame, keyword)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if source_order_no in last_text or order_title in last_text or self.filtered_order_list_has_single_result(last_text):
                return last_text
        return last_text

    def complete_player_recovery_from_my_penalties(
        self,
        source_order_no: str,
        order_title: str,
        *,
        amount: str,
        completion: str,
    ) -> str:
        frame = self.open_player_my_penalties()
        self.click_penalty_tab_if_present(frame, ["追缴单", "追缴"])
        text = self.search_player_my_penalties(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text or self.filtered_order_list_has_single_result(text), (
            f"打手应用-我的处罚未找到追缴单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        assert "追缴" in text or amount in text or f"{amount}.00" in text, (
            f"打手应用-我的处罚目标记录缺少追缴信息；source_order_no={source_order_no}; text={text[:1500]}"
        )
        opened = self.click_player_penalty_row_action(frame, source_order_no, order_title, ["查看", "详情", "去处理", "处理"])
        assert opened, f"打手应用-我的处罚追缴单未找到查看/处理入口；text={text[:1500]}"
        self.page.wait_for_timeout(1200)
        modal_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        if self.click_overlay_action(frame, ["提交完成", "完成追缴", "确认完成", "去完成"]):
            self.page.wait_for_timeout(1000)
            modal_text = self.visible_modal_text(frame) or modal_text
        self.fill_visible_modal_inputs_if_present(frame, completion)
        self.fill_modal_textarea_if_present(frame, completion)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        submitted = self.click_overlay_action(frame, ["提交完成", "确认完成", "完成追缴", "提交", "确定", "确认"])
        if not submitted:
            self.confirm_open_dialog(frame)
        self.page.wait_for_timeout(3500)
        final_text = self.search_player_my_penalties(frame, source_order_no, order_title)
        assert any(key in final_text for key in ["已完成", "完成", "已处理", amount, f"{amount}.00"]), (
            f"打手完成追缴后未看到完成结果；source_order_no={source_order_no}; "
            f"before_modal={modal_text[:1000]}; text={final_text[:1500]}"
        )
        return final_text

    def assert_player_direct_penalty_visible(
        self,
        source_order_no: str,
        order_title: str,
        *,
        amount: str,
    ) -> str:
        frame = self.open_player_my_penalties()
        self.click_penalty_tab_if_present(frame, ["罚单", "处罚单", "处罚"])
        text = self.search_player_my_penalties(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text or self.filtered_order_list_has_single_result(text), (
            f"打手应用-我的处罚未找到处罚单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        assert amount in text or f"{amount}.00" in text or f"¥{amount}" in text or f"￥{amount}" in text, (
            f"打手应用-我的处罚处罚单未看到处罚金额；source_order_no={source_order_no}; "
            f"amount={amount}; text={text[:1500]}"
        )
        assert "处罚" in text or "罚单" in text, (
            f"打手应用-我的处罚目标记录缺少处罚信息；source_order_no={source_order_no}; text={text[:1500]}"
        )
        return text

    def click_penalty_tab_if_present(self, frame: Frame, tab_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """tabTexts => {
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const targets = tabTexts.map(norm);
                const nodes = Array.from(document.querySelectorAll('[role="tab"], .ant-tabs-tab, button, a, span, div'))
                  .map(el => ({ el, text: norm(el.innerText || el.textContent || ''), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && item.text)
                  .filter(item => targets.some(target => item.text === target || item.text.includes(target)))
                  .sort((a, b) => {
                    const aTab = (a.el.getAttribute('role') === 'tab' || String(a.el.className || '').includes('tab')) ? 0 : 1;
                    const bTab = (b.el.getAttribute('role') === 'tab' || String(b.el.className || '').includes('tab')) ? 0 : 1;
                    return aTab - bTab || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0]?.el;
                if (!target) return false;
                target.click();
                return true;
            }""",
            tab_texts,
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def click_player_penalty_row_action(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_texts: list[str],
    ) -> bool:
        clicked = self.click_row_action_allow_revoke(frame, source_order_no, order_title, action_texts)
        if clicked:
            return True
        return self.click_overlay_action(frame, action_texts)

    def open_workshop_order_detail_and_click_sub_order_penalty_recovery(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
    ) -> bool:
        opened = self.open_workshop_order_detail_by_upstream_order_link(frame, source_order_no, order_title)
        if not opened:
            return False
        self.scroll_sub_order_table_to_right(frame)
        return self.click_sub_order_penalty_recovery_in_detail(frame)

    def open_workshop_order_detail_by_upstream_order_link(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
    ) -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const keywords = [sourceOrderNo, orderTitle].filter(Boolean);
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row, tbody tr, [class*="row"], [class*="order"]'))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.some(keyword => item.text.includes(keyword)));
                const row = rows[0];
                if (!row) return false;
                const nodes = Array.from(row.row.querySelectorAll('a, button, [role="button"], span, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => visible(item.el) && item.text)
                  .filter(item =>
                    /^BO\\d+/.test(item.text) ||
                    item.text.includes('订单详情') ||
                    item.text === '详情' ||
                    item.text.includes('查看')
                  )
                  .sort((a, b) => {
                    const rank = item => {
                      if (/^BO\\d+/.test(item.text)) return 0;
                      if (item.text.includes('订单详情') || item.text === '详情') return 1;
                      if (item.el.tagName === 'A') return 2;
                      return 3;
                    };
                    return rank(a) - rank(b) || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                const clickable = target.el.closest('a, button, [role="button"]') || target.el;
                const r = target.r;
                clickable.scrollIntoView({ block: 'center', inline: 'center' });
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }""",
            {"sourceOrderNo": source_order_no, "orderTitle": order_title},
        )
        if not clicked:
            clicked = (
                self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, "订单详情")
                or self.click_workshop_order_row_visible_text(frame, source_order_no, order_title, "详情")
                or self.click_row_action_allow_revoke(frame, source_order_no, order_title, ["订单详情", "详情", "查看", "操作", "操 作"])
            )
        if not clicked:
            return False
        self.page.wait_for_timeout(1500)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        return "订单详情" in detail_text or "子单列表" in detail_text or "处理追缴" in detail_text

    def click_sub_order_penalty_recovery_in_detail(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(visible);
                const root = roots.pop() || document;
                const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span, div'))
                  .map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').replace(/\\s+/g, ''),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && item.text)
                  .filter(item => ['处理追缴', '处罚追缴', '追缴', '处罚'].some(text => item.text === text || item.text.includes(text)))
                  .sort((a, b) => {
                    const rank = item => {
                      if (item.text === '处理追缴') return 0;
                      if (item.text.includes('处理追缴')) return 1;
                      if (item.el.closest('button, a, [role="button"]')) return 2;
                      return 3;
                    };
                    return rank(a) - rank(b) || (a.r.width * a.r.height) - (b.r.width * b.r.height) || b.r.x - a.r.x;
                  });
                const target = nodes[0];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                const r = target.r;
                clickable.scrollIntoView({ block: 'center', inline: 'center' });
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }"""
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def click_workshop_appeal_pending_action(self, frame: Frame, upstream_order_no: str, action_text: str) -> bool:
        return self.click_workshop_appeal_status_action(frame, upstream_order_no, "待处理", action_text)

    def click_workshop_appeal_status_action(self, frame: Frame, upstream_order_no: str, status_text: str, action_text: str) -> bool:
        clicked = frame.evaluate(
            """({ upstreamOrderNo, statusText, actionText }) => {
                const normalize = value => String(value || '').replace(/\\s+/g, '');
                const visiblePrefix = upstreamOrderNo && upstreamOrderNo.length > 13 ? upstreamOrderNo.slice(0, 13) : upstreamOrderNo;
                const targetStatus = normalize(statusText);
                const targetAction = normalize(actionText);
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row, tbody tr'))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(upstreamOrderNo) || (visiblePrefix && item.text.includes(visiblePrefix)))
                  .filter(item => {
                    const compact = normalize(item.text);
                    return compact.includes(targetStatus) && !compact.includes('已完成') && !compact.includes('已拒绝');
                  });
                for (const item of rows) {
                  const actions = Array.from(item.row.querySelectorAll('button, a, [role="button"], span'))
                    .map(el => ({
                      el,
                      text: el.innerText || el.textContent || '',
                      r: el.getBoundingClientRect(),
                      disabled: el.disabled,
                      tag: el.tagName.toLowerCase()
                    }))
                    .filter(node => node.r.width > 0 && node.r.height > 0 && !node.disabled)
                    .filter(node => {
                      const text = normalize(node.text);
                      return text === targetAction || (text.includes(targetAction) && text.length <= targetAction.length + 6);
                    })
                    .sort((a, b) => {
                      const aExact = normalize(a.text) === targetAction ? 0 : 1;
                      const bExact = normalize(b.text) === targetAction ? 0 : 1;
                      const aTag = ['button', 'a'].includes(a.tag) ? 0 : 1;
                      const bTag = ['button', 'a'].includes(b.tag) ? 0 : 1;
                      return aExact - bExact || aTag - bTag || b.r.x - a.r.x;
                    });
                  const target = actions[0];
                  if (!target) continue;
                  const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                  const r = target.r;
                  clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.click();
                  return true;
                }
                return false;
            }""",
            {"upstreamOrderNo": upstream_order_no, "statusText": status_text, "actionText": action_text},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def open_workshop_order_list_menu_once(self) -> None:
        try:
            self.click_sidebar_text("工作室履约应用", right_edge=True)
        except AssertionError:
            assert self.click_sidebar_visible_text("工作室履约应用", right_edge=True) or self.click_left_sidebar_text_contains("工作室履约应用", right_edge=True), (
                f"未找到工作室履约应用菜单；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.page.wait_for_timeout(800)
        try:
            self.click_sidebar_text("订单列表")
        except AssertionError:
            assert self.click_sidebar_visible_text("订单列表") or self.click_left_sidebar_text_contains("订单列表"), (
                f"未找到工作室履约应用-订单列表入口；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )

    def click_left_sidebar_text_contains(self, text: str, right_edge: bool = False) -> bool:
        box = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, a, button'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 230 && item.r.y > 40)
                  .filter(item => item.r.width > 20 && item.r.width < 230 && item.r.height > 10 && item.r.height < 90)
                  .filter(item => item.value === text || item.value.includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return a.r.y - b.r.y || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return null;
                return { x: target.r.x, y: target.r.y, width: target.r.width, height: target.r.height };
            }""",
            text,
        )
        if not box:
            return False
        x = box["x"] + box["width"] - 16 if right_edge else box["x"] + min(box["width"] / 2, 90)
        self.page.mouse.click(x, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)
        return True

    def click_sidebar_visible_text(self, text: str, right_edge: bool = False) -> bool:
        clicked = self.page.evaluate(
            """({ text, rightEdge }) => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 220 && item.r.y > 40)
                  .filter(item => item.r.width > 10 && item.r.width < 220 && item.r.height > 10)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    const aLeaf = a.r.height <= 48 ? 0 : 1;
                    const bLeaf = b.r.height <= 48 ? 0 : 1;
                    if (aLeaf !== bLeaf) return aLeaf - bLeaf;
                    return a.r.y - b.r.y || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                let clickable = target.el;
                for (let node = target.el; node && node !== document.body; node = node.parentElement) {
                  const r = node.getBoundingClientRect();
                  const style = window.getComputedStyle(node);
                  const cls = String(node.className || '');
                  if (
                    r.x >= 0 && r.x < 220 && r.width > 80 && r.width < 220 && r.height >= 20 && r.height <= 56 &&
                    (style.cursor === 'pointer' || cls.includes('menu') || cls.includes('item') || node.tagName === 'TARO-VIEW-CORE')
                  ) {
                    clickable = node;
                    break;
                  }
                }
                const r = clickable.getBoundingClientRect();
                const x = rightEdge ? r.x + r.width - 16 : r.x + Math.min(r.width / 2, 90);
                const y = r.y + r.height / 2;
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
                clickable.click();
                return true;
            }""",
            {"text": text, "rightEdge": right_edge},
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    @staticmethod
    def workshop_order_list_is_ready(text: str) -> bool:
        return "订单列表" in text or (
            "来源单号" in text
            and ("子单状态" in text or "订单编号" in text)
            and ("查 询" in text or "查询" in text)
        )

    def open_player_order_hall(self) -> list[Frame]:
        last_text = ""
        for attempt in range(3):
            if attempt == 0:
                self.goto(WANYOUJI_CONSOLE_URL)
            else:
                self.page.reload()
                self.page.wait_for_load_state("domcontentloaded")
            self.wait_for_enterprise_console_shell()
            self.click_sidebar_text("打手应用", right_edge=True)
            try:
                self.click_sidebar_text("抢单大厅")
                break
            except AssertionError:
                if self.click_visible_text_anywhere("抢单大厅"):
                    break
                last_text = self.page.locator("body").inner_text(timeout=5000)
                self.page.wait_for_timeout(1500)
        else:
            assert False, f"刷新重试后仍未找到打手应用-抢单大厅入口；text={last_text[:1200]}"
        self.page.wait_for_timeout(4000)
        frames = [
            frame
            for frame in self.page.frames
            if "about:blank" not in frame.url
            and self.frame_contains_any(frame, ["抢单大厅", "立即抢单", "订单标题", "来源单号"])
        ]
        if not frames:
            urls = [frame.url for frame in self.page.frames]
            raise AssertionError(f"未找到打手应用-抢单大厅 iframe；frames={urls}")
        return frames

    def open_player_assigned_order_hall(self) -> list[Frame]:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        self.click_sidebar_parent_strict("打手应用") or self.click_visible_text_anywhere("打手应用")
        if not self.click_sidebar_leaf_strict("抢单大厅"):
            assert self.click_visible_text_anywhere("抢单大厅"), (
                f"未找到打手应用-抢单大厅入口；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.page.wait_for_timeout(4000)
        frames = [
            frame
            for frame in self.page.frames
            if "about:blank" not in frame.url
            and self.frame_contains_any(frame, ["抢单大厅", "公池单", "指派单", "待接单", "订单号"])
        ]
        for frame in frames:
            self.click_order_hall_tab(frame, "指派单")
        self.page.wait_for_timeout(1200)
        assigned_frames = [
            frame
            for frame in frames
            if self.frame_contains_any(frame, ["指派单", "订单号", "待接单"])
        ]
        if not assigned_frames:
            urls = [frame.url for frame in self.page.frames]
            raise AssertionError(f"未找到打手应用-抢单大厅-指派单 iframe；frames={urls}")
        return assigned_frames

    def click_visible_text_anywhere(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button, a'));
                const candidates = nodes
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = candidates[0];
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def verify_order_not_in_hall(self, source_order_no: str, order_title: str, expected_scope: str) -> None:
        frames = self.open_order_hall()
        found_texts: list[str] = []
        for scope_text in self.scope_candidates(expected_scope):
            for frame in frames:
                if scope_text == "私单" and not self.frame_has_order_hall_tabs(frame):
                    continue
                self.click_order_hall_tab(frame, scope_text)
                self.page.wait_for_timeout(1000)
                self.search_order_if_possible(frame, source_order_no)
                text = frame.locator("body").inner_text(timeout=5000)
                found_texts.append(f"[{scope_text}] {text[:1000]}")
                assert source_order_no not in text and order_title not in text, (
                    f"Wanyouji order hall still showed revoked order; scope={scope_text}; "
                    f"source_order_no={source_order_no}; order_title={order_title}; page_text={text[:1500]}"
                )
        assert found_texts, f"Could not open Wanyouji order hall after revoke; expected_scope={expected_scope}"

    def frame_has_order_hall_tabs(self, frame: Frame) -> bool:
        try:
            text = frame.locator("body").inner_text(timeout=3000)
        except Exception:
            return False
        return ("公池单" in text and "私单" in text) or "抢单大厅" in text

    def click_order_hall_tab(self, frame: Frame, tab_text: str) -> bool:
        clicked = frame.evaluate(
            """tabText => {
                const nodes = Array.from(document.querySelectorAll(
                    '.ant-tabs-tab, .ant-tabs-tab-btn, button, span, div, a'
                ));
                const candidates = nodes
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === tabText)
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = candidates[0];
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            tab_text,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def verify_order_in_workshop_order_list(self, source_order_no: str, order_title: str) -> bool:
        try:
            frame = self.open_workshop_order_list()
            text = self.search_workshop_order_list(frame, source_order_no, order_title)
            if source_order_no in text or order_title in text:
                return True
            for frame in self.wanyouji_business_frames():
                for keyword in [source_order_no, order_title]:
                    self.search_order_if_possible(frame, keyword)
                    text = frame.locator("body").inner_text(timeout=5000)
                    if source_order_no in text or order_title in text:
                        return True
        except Exception:
            return False
        return False

    def apply_upstream_acceptance(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"丸友集主账号订单列表未找到待申请上游验收订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        if self.order_waiting_upstream_acceptance(text, source_order_no):
            return text
        row_text = self.extract_order_row_text(text, source_order_no)
        assert "申请上游验收" in row_text, (
            f"S-FLOW-045 前置不满足：目标订单当前不能直接申请上游验收；"
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}"
        )
        clicked = self.click_workshop_order_list_action_exact(frame, source_order_no, "申请上游验收")
        assert clicked, (
            f"丸友集主账号订单列表未找到申请上游验收按钮；source_order_no={source_order_no}; "
            f"text={text[:1500]}"
        )
        self.submit_upstream_acceptance_modal(frame, source_order_no)
        self.page.wait_for_timeout(4000)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        if not self.order_waiting_upstream_acceptance(final_text, source_order_no) and "申请上游验收" in final_row_text:
            final_text = self.reload_workshop_order_list_and_search(frame, source_order_no, order_title)
            final_row_text = self.extract_order_row_text(final_text, source_order_no)
        assert self.order_waiting_upstream_acceptance(final_text, source_order_no) or "申请上游验收" not in final_row_text, (
            f"丸友集申请上游验收后订单状态未变化；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; modal={self.visible_modal_text(frame)[:1000]}"
        )
        return final_text

    def accept_sub_order_and_verify_ready_for_upstream(
        self,
        source_order_no: str,
        order_title: str,
        frame: Frame | None = None,
        current_text: str = "",
    ) -> str:
        frame = frame or self.open_workshop_order_list()
        text = current_text or self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"丸友集主账号订单列表未找到待验收子单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        self.approve_sub_order_acceptance(frame, source_order_no, order_title, current_text=text)
        self.page.wait_for_timeout(2500)
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert source_order_no in final_text or order_title in final_text, (
            f"丸友集验收子单后订单列表未找到订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={final_text[:1500]}"
        )
        row_text = self.extract_order_row_text(final_text, source_order_no)
        assert any(key in row_text or key in final_text for key in ["申请上游验收", "已完成", "完成", "等待上游验收"]), (
            f"丸友集验收子单后未看到已完成/可申请上游验收状态；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def reject_sub_order_acceptance_and_verify_in_progress(
        self,
        source_order_no: str,
        order_title: str,
        reason: str,
    ) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"丸友集主账号订单列表未找到待拒绝验收子单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        row_text = self.extract_order_row_text(text, source_order_no)
        assert "待验收" in row_text or "验:" in row_text or "验：1" in row_text, (
            f"丸友集拒绝验收前置不满足待验收；source_order_no={source_order_no}; row_text={row_text[:1500]}"
        )
        clicked = self.click_workshop_order_list_action(frame, source_order_no, "操作")
        assert clicked, (
            f"丸友集主账号订单列表未找到操作按钮，无法拒绝验收子单；source_order_no={source_order_no}; "
            f"text={text[:1500]}"
        )
        self.page.wait_for_timeout(1000)
        self.scroll_sub_order_table_to_right(frame)
        rejected = self.click_sub_order_row_acceptance_action(frame, ["拒绝验收", "驳回验收", "拒绝", "驳回"])
        assert rejected, (
            f"点击操作后未在子单列表行内找到【拒绝验收】入口；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(1000)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        if self.visible_modal_has_file_input(frame):
            self.upload_single_modal_image(frame)
        confirmed = self.click_overlay_action(frame, ["确认拒绝", "确认驳回", "拒绝验收", "驳回验收", "提交", "确定", "确认"])
        if not confirmed:
            confirmed = self.click_latest_confirm_primary_button(frame)
        assert confirmed, (
            f"子单【拒绝验收】后未找到确认/提交按钮；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.close_sub_order_modal(frame)
        self.page.wait_for_timeout(2500)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        assert any(key in final_row_text for key in ["代练中", "服务中", "已接单", "代: 1"]) and "待验收" not in final_row_text, (
            f"丸友集拒绝验收后目标子单未恢复代练中/服务中；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    def cancel_upstream_acceptance(self, source_order_no: str, order_title: str, reason: str) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"丸友集主账号订单列表未找到待取消上游验收订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        row_text = self.extract_order_row_text(text, source_order_no)
        assert self.order_waiting_upstream_acceptance(text, source_order_no) or "取消验收" in row_text, (
            f"丸友集取消上游验收前置不满足待验收；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}"
        )
        clicked = self.click_workshop_order_list_action(frame, source_order_no, "取消验收")
        if not clicked:
            clicked = self.click_workshop_order_dropdown_action(frame, source_order_no, order_title, "取消验收")
        assert clicked, (
            f"丸友集主账号订单列表未找到【取消验收】按钮；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={text[:1500]}"
        )
        self.page.wait_for_timeout(800)
        self.fill_visible_modal_inputs_if_present(frame, reason)
        self.fill_modal_textarea_if_present(frame, reason)
        confirmed = self.click_overlay_action(frame, ["确认取消", "取消验收", "确认提交", "提交", "确定", "确认"])
        if not confirmed:
            confirmed = self.click_latest_confirm_primary_button(frame)
        assert confirmed, (
            f"取消上游验收弹框未找到确认按钮；source_order_no={source_order_no}; "
            f"modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(3000)
        final_text = self.search_workshop_order_list(frame, source_order_no, order_title)
        final_row_text = self.extract_order_row_text(final_text, source_order_no)
        if "取消验收" in final_row_text and "申请上游验收" not in final_row_text:
            final_text = self.reload_workshop_order_list_and_search(frame, source_order_no, order_title)
            final_row_text = self.extract_order_row_text(final_text, source_order_no)
        assert source_order_no in final_text or order_title in final_text, (
            f"丸友集取消上游验收后订单列表未找到目标订单；source_order_no={source_order_no}; "
            f"text={final_text[:1500]}"
        )
        assert "取消验收" not in final_row_text or "申请上游验收" in final_row_text, (
            f"丸友集取消上游验收后订单仍停留在可取消验收状态；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}"
        )
        assert any(key in final_row_text for key in ["申请上游验收", "已完成", "完成", "服务中", "代练中", "代:"]), (
            f"丸友集取消上游验收后未看到回退后的履约/可申请状态；source_order_no={source_order_no}; "
            f"row_text={final_row_text[:1500]}; text={final_text[:1500]}"
        )
        return final_text

    @staticmethod
    def order_waiting_upstream_acceptance(text: str, source_order_no: str) -> bool:
        row_text = WanyoujiFulfillmentPage.extract_order_row_text(text, source_order_no)
        return "等待上游验收" in row_text or ("待验收" in row_text and "申请验收时间" in text and "终:" in row_text)

    def refresh_workshop_order_list_and_search(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        self.click_optional_text(frame, "刷 新") or self.click_optional_text(frame, "刷新")
        self.page.wait_for_timeout(2500)
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        if source_order_no in text or order_title in text:
            return text
        self.page.reload()
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(3500)
        refreshed_frame = self.open_workshop_order_list()
        return self.search_workshop_order_list(refreshed_frame, source_order_no, order_title)

    def reload_workshop_order_list_and_search(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        self.click_optional_text(frame, "刷 新") or self.click_optional_text(frame, "刷新")
        self.page.wait_for_timeout(1500)
        self.page.reload()
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(3500)
        refreshed_frame = self.open_workshop_order_list()
        return self.search_workshop_order_list(refreshed_frame, source_order_no, order_title)

    @staticmethod
    def extract_order_row_text(text: str, source_order_no: str) -> str:
        index = text.find(source_order_no)
        if index < 0:
            return text
        next_index = text.find("来源单号：", index + len(source_order_no))
        if next_index < 0:
            next_index = text.find("共 ", index + len(source_order_no))
        return text[index : next_index if next_index > index else len(text)]

    def approve_sub_order_acceptance(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        current_text: str = "",
    ) -> None:
        row_text = current_text or frame.locator("body").inner_text(timeout=5000)
        if self.order_waiting_upstream_acceptance(row_text, source_order_no):
            return
        if "待验收" not in row_text and "验:" not in row_text:
            return
        opened = self.open_acceptance_sub_order_list(frame, source_order_no, order_title)
        if not opened:
            clicked = self.click_workshop_order_list_action(frame, source_order_no, "操作")
            assert clicked, (
                f"丸友集主账号订单列表未找到待验收子单入口或操作按钮，无法审批子单；source_order_no={source_order_no}; "
                f"text={row_text[:1500]}"
            )
            self.page.wait_for_timeout(1000)
        self.scroll_sub_order_table_to_right(frame)
        approved = self.click_sub_order_row_acceptance(frame)
        assert approved, (
            f"点击操作后未在子单列表行内找到【同意验收】入口；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.confirm_sub_order_acceptance(frame, source_order_no)
        self.close_sub_order_modal(frame)
        self.page.wait_for_timeout(1500)

    def open_acceptance_sub_order_list(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        modal_text = self.visible_modal_text(frame)
        if "子单列表" in modal_text and ("待验收" in modal_text or "同意验收" in modal_text):
            return True
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const keywords = [sourceOrderNo, orderTitle].filter(Boolean);
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row, tbody tr, tr'))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.some(keyword => item.text.includes(keyword)));
                const norm = value => String(value || '').replace(/\\s+/g, '');
                for (const item of rows) {
                  const nodes = Array.from(item.row.querySelectorAll('button, a, [role="button"], span, div, td'))
                    .map(el => {
                      const text = norm(el.innerText || el.textContent || '');
                      const r = el.getBoundingClientRect();
                      const clickable = el.closest('button, a, [role="button"]') || el;
                      return { el, clickable, text, r };
                    })
                    .filter(node => node.r.width > 0 && node.r.height > 0 && node.text)
                    .filter(node =>
                      /^验[:：]?\\d+$/.test(node.text) ||
                      /^待验收$/.test(node.text) ||
                      node.text.includes('验:') ||
                      node.text.includes('验：')
                    )
                    .sort((a, b) => {
                      const aExact = /^验[:：]?\\d+$/.test(a.text) ? 0 : 1;
                      const bExact = /^验[:：]?\\d+$/.test(b.text) ? 0 : 1;
                      return aExact - bExact || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                    });
                  const target = nodes[0];
                  if (!target) continue;
                  target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                  const r = target.r;
                  target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.clickable.click();
                  return true;
                }
                return false;
            }""",
            {"sourceOrderNo": source_order_no, "orderTitle": order_title},
        )
        self.page.wait_for_timeout(1200)
        if not clicked:
            return False
        for _ in range(8):
            modal_text = self.visible_modal_text(frame)
            if "子单列表" in modal_text and ("待验收" in modal_text or "同意验收" in modal_text):
                return True
            self.page.wait_for_timeout(500)
        return False

    def scroll_sub_order_table_to_right(self, frame: Frame) -> None:
        frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const scrollers = Array.from(root.querySelectorAll('.ant-table-content, .ant-table-body'))
                  .filter(el => el.scrollWidth > el.clientWidth);
                for (const el of scrollers) {
                  el.scrollLeft = el.scrollWidth;
                  el.dispatchEvent(new Event('scroll', { bubbles: true }));
                }
            }"""
        )
        self.page.wait_for_timeout(800)

    def click_workshop_order_list_action(self, frame: Frame, source_order_no: str, action_text: str) -> bool:
        row = frame.locator("tbody tr.ant-table-row").filter(has_text=source_order_no).first
        if row.count() == 0:
            body_text = frame.locator("body").inner_text(timeout=5000)
            if self.filtered_order_list_has_single_result(body_text):
                row = frame.locator("tbody tr.ant-table-row").first
        if row.count():
            action = row.locator("button, a, [role='button']").filter(has_text=action_text).last
            if action.count():
                action.click(force=True)
                self.page.wait_for_timeout(1000)
                return True
        clicked = frame.evaluate(
            """({ sourceOrderNo, actionText }) => {
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(sourceOrderNo));
                if (!rows.length && /共\\s*1\\s*条记录/.test(document.body.innerText || '')) {
                  const first = Array.from(document.querySelectorAll('tbody tr.ant-table-row'))
                    .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                    .filter(item => item.r.width > 0 && item.r.height > 0)[0];
                  if (first) rows.push(first);
                }
                for (const item of rows) {
                  const actions = Array.from(item.row.querySelectorAll('button, a, [role="button"], span'))
                    .map(el => ({
                      el,
                      text: (el.innerText || el.textContent || '').trim(),
                      r: el.getBoundingClientRect(),
                      disabled: el.disabled,
                      tag: el.tagName.toLowerCase()
                    }))
                    .filter(node => node.r.width > 0 && node.r.height > 0 && !node.disabled)
                    .filter(node => {
                      const normalized = node.text.replace(/\\s+/g, '');
                      const target = actionText.replace(/\\s+/g, '');
                      return normalized === target || (normalized.includes(target) && normalized.length <= target.length + 6);
                    })
                    .sort((a, b) => {
                      const aExact = a.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, '') ? 0 : 1;
                      const bExact = b.text.replace(/\\s+/g, '') === actionText.replace(/\\s+/g, '') ? 0 : 1;
                      const aTag = ['button', 'a'].includes(a.tag) ? 0 : 1;
                      const bTag = ['button', 'a'].includes(b.tag) ? 0 : 1;
                      return aExact - bExact || aTag - bTag || (a.r.width * a.r.height) - (b.r.width * b.r.height) || b.r.x - a.r.x;
                    });
                  const target = actions[0];
                  if (!target) continue;
                  const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                  const r = target.r;
                  clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.click();
                  return true;
                }
                return false;
            }""",
            {"sourceOrderNo": source_order_no, "actionText": action_text},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_workshop_order_list_action_exact(self, frame: Frame, source_order_no: str, action_text: str) -> bool:
        row = frame.locator("tbody tr.ant-table-row").filter(has_text=source_order_no).first
        if row.count() == 0:
            body_text = frame.locator("body").inner_text(timeout=5000)
            if self.filtered_order_list_has_single_result(body_text):
                row = frame.locator("tbody tr.ant-table-row").first
        if row.count() == 0:
            return False
        action = row.locator("button, a, [role='button']").filter(has_text=action_text).last
        if action.count() == 0:
            return False
        action.click(force=True)
        self.page.wait_for_timeout(1000)
        return True

    def click_workshop_order_dropdown_action(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_text: str,
    ) -> bool:
        opened = self.click_workshop_order_operation_button(frame, source_order_no, order_title)
        if not opened:
            return False
        self.page.wait_for_timeout(800)
        target_box = frame.evaluate(
            """actionText => {
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const targetText = norm(actionText);
                const roots = Array.from(document.querySelectorAll('.ant-dropdown, .ant-popover'))
                  .map(el => ({ el, r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => b.r.y - a.r.y || b.r.x - a.r.x);
                for (const { el } of roots) {
                  const nodes = Array.from(el.querySelectorAll('button, a, [role="button"], li, span, div'))
                    .map(node => {
                      const clickable = node.closest('button, a, [role="button"], li') || node;
                      return {
                        node,
                        clickable,
                        text: (node.innerText || node.textContent || '').trim(),
                        clickableText: (clickable.innerText || clickable.textContent || '').trim(),
                        r: node.getBoundingClientRect(),
                        disabled: node.disabled || clickable.disabled || clickable.getAttribute('aria-disabled') === 'true'
                      };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .filter(item => norm(item.text) === targetText || norm(item.clickableText) === targetText)
                  .sort((a, b) => {
                      const aLi = a.clickable.tagName === 'LI' ? 0 : 1;
                      const bLi = b.clickable.tagName === 'LI' ? 0 : 1;
                      return aLi - bLi || (b.r.width * b.r.height) - (a.r.width * a.r.height);
                    });
                  const target = nodes[0];
                  if (!target) continue;
                  const r = target.clickable.getBoundingClientRect();
                  return { x: r.x + r.width / 2, y: r.y + r.height / 2, text: target.clickableText || target.text };
                }
                return null;
            }""",
            action_text,
        )
        if not target_box:
            return False
        self.page.mouse.move(target_box["x"], target_box["y"])
        self.page.mouse.down()
        self.page.mouse.up()
        self.page.wait_for_timeout(1200)
        return True

    def click_workshop_order_operation_button(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        target_box = frame.evaluate(
            """({ sourceOrderNo, orderTitle }) => {
                const keywords = [sourceOrderNo, orderTitle].filter(Boolean);
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row, tbody tr, tr'))
                  .map(row => ({ row, text: row.innerText || row.textContent || '', r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => keywords.some(keyword => item.text.includes(keyword)))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const row = rows[0]?.row;
                if (!row) return null;
                const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span, div'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    const text = norm(el.innerText || el.textContent || '');
                    const clickableText = norm(clickable.innerText || clickable.textContent || text);
                    const r = clickable.getBoundingClientRect();
                    return { el, clickable, text, clickableText, r };
                  })
                  .filter(item => visible(item.clickable))
                  .filter(item => item.text === '操作' || item.clickableText === '操作')
                  .sort((a, b) => b.r.x - a.r.x || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = nodes[0];
                if (!target) return null;
                return { x: target.r.x + target.r.width / 2, y: target.r.y + target.r.height / 2 };
            }""",
            {"sourceOrderNo": source_order_no, "orderTitle": order_title},
        )
        if not target_box:
            return False
        self.page.mouse.move(target_box["x"], target_box["y"])
        self.page.mouse.down()
        self.page.mouse.up()
        self.page.wait_for_timeout(1000)
        return True

    def wait_for_revoke_order_form_text(self, frame: Frame, timeout_ms: int = 8000) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if last_text and "申请撤销" in last_text and any(
                key in last_text for key in ["原因", "说明", "备注", "上传", "金额", "确认撤销", "提交"]
            ):
                return last_text
            self.page.wait_for_timeout(500)
        return ""

    def click_sub_order_row_acceptance(self, frame: Frame) -> bool:
        return self.click_sub_order_row_acceptance_action(frame, ["同意验收", "同意"])

    def click_sub_order_row_acceptance_action(self, frame: Frame, action_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """actionTexts => {
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const targets = actionTexts.map(norm);
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const directLinks = Array.from(root.querySelectorAll('a.index-module__actionLink___jPCYP, a, button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .filter(item => targets.includes(norm(item.text)));
                if (directLinks.length) {
                  const target = directLinks.sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                  target.el.scrollIntoView({ block: 'center', inline: 'center' });
                  const r = target.r;
                  target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  target.el.click();
                  return true;
                }
                const links = Array.from(root.querySelectorAll('.ant-modal-body a, .ant-modal-body button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                let target =
                  links.find(item => targets.includes(norm(item.text))) ||
                  links.find(item => targets.some(targetText => norm(item.text).includes(targetText)));
                if (!target && targets.some(targetText => targetText.includes('同意'))) {
                  target = links.find(item => item.r.x > root.getBoundingClientRect().right - 260 && item.r.y > root.getBoundingClientRect().top + 220);
                }
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            action_texts,
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def confirm_sub_order_acceptance(self, frame: Frame, source_order_no: str) -> None:
        self.page.wait_for_timeout(1000)
        clicked = self.click_latest_confirm_primary_button(frame)
        assert clicked, (
            f"子单【同意验收】后未找到确认弹窗主按钮；source_order_no={source_order_no}; "
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(2500)

    def submit_upstream_acceptance_modal(self, frame: Frame, source_order_no: str) -> None:
        last_modal_text = ""
        clicked_any = False
        for _ in range(5):
            self.page.wait_for_timeout(800)
            modal_text = self.visible_modal_text(frame)
            if not modal_text:
                if clicked_any:
                    return
                self.confirm_open_dialog(frame)
                clicked_any = True
                continue
            last_modal_text = modal_text
            if self.visible_modal_has_file_input(frame) and not self.visible_modal_has_uploaded_file(frame):
                self.upload_single_modal_image(frame)
            self.fill_visible_modal_inputs_if_present(frame, f"E2E申请上游验收 {source_order_no}")
            clicked = self.click_overlay_action(
                frame,
                ["确认申请", "确认提交", "提交申请", "申请验收", "提交", "确认", "确定", "确 认", "确 定"],
            )
            if not clicked:
                clicked = self.click_latest_confirm_primary_button(frame)
            assert clicked, (
                f"申请上游验收弹框未找到提交/确认按钮；source_order_no={source_order_no}; "
                f"modal_text={last_modal_text[:1500]}"
            )
            clicked_any = True
            self.page.wait_for_timeout(1800)
            next_modal_text = self.visible_modal_text(frame)
            if not next_modal_text:
                return
            last_modal_text = next_modal_text
        raise AssertionError(
            f"申请上游验收弹框多次确认后仍未关闭；source_order_no={source_order_no}; "
            f"modal_text={last_modal_text[:1500]}"
        )

    def close_sub_order_modal(self, frame: Frame) -> None:
        closed = self.click_current_modal_footer_button(frame, "关闭子单")
        if not closed:
            closed = self.click_current_modal_footer_button(frame, "关闭")
        if not closed:
            self.close_current_modal(frame)
        self.page.wait_for_timeout(1200)

    def click_latest_confirm_primary_button(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-popover, .ant-popconfirm, .ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled, cls: String(el.className || '') }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                if (!buttons.length) return false;
                const target =
                  buttons.find(item => item.cls.includes('primary')) ||
                  buttons.find(item => ['确认', '确定', '确 认', '确 定'].includes(item.text)) ||
                  buttons[buttons.length - 1];
                target.el.click();
                return true;
            }"""
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def click_visible_confirm_button(self, frame: Frame, button_text: str) -> bool:
        clicked = frame.evaluate(
            """buttonText => {
                const roots = Array.from(document.querySelectorAll('.ant-popover, .ant-popconfirm, .ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = buttons.find(item => item.text === buttonText) ||
                  buttons.find(item => item.text.replace(/\\s+/g, '') === buttonText.replace(/\\s+/g, ''));
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            button_text,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def close_current_modal(self, frame: Frame) -> None:
        frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                const button = root?.querySelector('.ant-modal-close');
                button?.click();
            }"""
        )
        self.page.wait_for_timeout(1200)

    def click_current_modal_footer_button(self, frame: Frame, button_text: str) -> bool:
        clicked = frame.evaluate(
            """buttonText => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const footer = root.querySelector('.ant-modal-footer') || root;
                const buttons = Array.from(footer.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = buttons.find(item => item.text === buttonText);
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            button_text,
        )
        return bool(clicked)

    def upload_single_modal_image(self, frame: Frame) -> None:
        attachment = self.delivery_attachment_path()
        file_inputs = frame.locator(".ant-modal input[type='file']")
        assert file_inputs.count() > 0, (
            "子单验收弹框未找到图片上传控件；"
            f"text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        file_inputs.first.set_input_files(str(attachment))
        self.page.wait_for_timeout(1200)
        self.wait_modal_upload_complete(frame, timeout_ms=8000)

    def visible_modal_has_uploaded_file(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    if (!root) return false;
                    return Array.from(root.querySelectorAll(
                      '.ant-upload-list-item, [class*="upload-list-item"], .ant-upload-list img, [class*="upload-list"] img'
                    )).some(el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    });
                }"""
            )
        )

    def visible_modal_has_file_input(self, frame: Frame) -> bool:
        return bool(
            frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    if (!root) return false;
                    return Array.from(root.querySelectorAll('input[type="file"]')).length > 0 &&
                      (root.innerText || root.textContent || '').includes('上传');
                }"""
            )
        )

    def fill_modal_amount_if_present(self, frame: Frame, amount: str) -> None:
        frame.evaluate(
            """amount => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return false;
                const inputs = Array.from(root.querySelectorAll('input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .map(el => ({
                    el,
                    placeholder: el.getAttribute('placeholder') || '',
                    text: (el.closest('.ant-form-item, [class*="form-item"]')?.innerText || ''),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = inputs.find(item =>
                  item.placeholder.includes('金额') ||
                  item.placeholder.includes('价格') ||
                  item.placeholder.includes('退款') ||
                  item.text.includes('金额') ||
                  item.text.includes('价格') ||
                  item.text.includes('退款')
                ) || inputs.find(item => item.el.getAttribute('type') === 'number');
                if (!target) return false;
                const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                setter.call(target.el, amount);
                target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: amount }));
                target.el.dispatchEvent(new Event('change', { bubbles: true }));
                target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                return true;
            }""",
            amount,
        )
        self.page.wait_for_timeout(400)

    def visible_modal_text(self, frame: Frame) -> str:
        script = """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop();
                    return root ? (root.innerText || root.textContent || '') : '';
                }"""
        text = str(frame.evaluate(script))
        if text:
            return text
        return str(self.page.evaluate(script))

    def fill_visible_modal_inputs_if_present(self, frame: Frame, value: str) -> None:
        frame.evaluate(
            """value => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop();
                if (!root) return;
                const controls = Array.from(root.querySelectorAll('textarea, input'))
                  .filter(el => el.getAttribute('type') !== 'file' && el.getAttribute('type') !== 'hidden')
                  .filter(el => !el.disabled && !el.readOnly)
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                for (const el of controls) {
                  const current = String(el.value || '').trim();
                  if (current) continue;
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                }
            }""",
            value,
        )
        self.page.wait_for_timeout(500)

    def click_overlay_action(self, frame: Frame, action_texts: list[str]) -> bool:
        clicked = frame.evaluate(
            """actionTexts => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, .ant-popover, .ant-dropdown, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                for (const actionText of actionTexts) {
                  const target = nodes.find(item => item.text === actionText) ||
                    nodes.find(item => item.text.includes(actionText));
                  if (target) {
                    const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                    clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            action_texts,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def verify_workshop_order_completed_or_settling(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"丸友集主账号订单列表未找到完结后的订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        assert any(key in text for key in ["已完成", "已完结", "待结算", "结算", "完成"]), (
            f"丸友集主账号订单列表未看到订单完结/结算状态；source_order_no={source_order_no}; "
            f"text={text[:1500]}"
        )
        return text

    def verify_player_income_for_order(
        self,
        source_order_no: str,
        order_title: str,
        expected_amount: str = "70",
    ) -> str:
        try:
            frame = self.open_player_income()
        except AssertionError as exc:
            return self.verify_completed_player_order_as_income_fallback(
                source_order_no,
                order_title,
                expected_amount=expected_amount,
                menu_error=str(exc),
            )
        self.click_player_income_order_tab(frame)
        last_text = ""
        for keyword in [source_order_no, order_title]:
            self.reset_search_if_possible(frame)
            self.search_order_if_possible(frame, keyword)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if source_order_no in last_text or order_title in last_text:
                assert expected_amount in last_text or f"¥{expected_amount}" in last_text, (
                    f"打手应用-我的收入找到订单但未看到预期金额；source_order_no={source_order_no}; "
                    f"expected_amount={expected_amount}; text={last_text[:1500]}"
                )
                return last_text
            if self.open_player_income_detail_if_possible(frame, source_order_no, order_title, expected_amount):
                detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
                if source_order_no in detail_text or order_title in detail_text:
                    assert expected_amount in detail_text or f"¥{expected_amount}" in detail_text, (
                        f"打手应用-我的收入详情找到订单但未看到预期金额；source_order_no={source_order_no}; "
                        f"expected_amount={expected_amount}; text={detail_text[:1500]}"
                    )
                    return detail_text
                self.close_current_modal(frame)
        fallback_text = self.player_income_latest_completed_row_text(frame, order_title, expected_amount)
        if fallback_text:
            return fallback_text
        raise AssertionError(
            f"打手应用-我的收入未找到完结订单收入；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={last_text[:1500]}"
        )

    def player_income_latest_completed_row_text(self, frame: Frame, order_title: str, expected_amount: str = "") -> str:
        row_text = str(
            frame.evaluate(
                """({ orderTitle, expectedAmount }) => {
                    const compactTitle = (orderTitle || '').replace(/\\s+/g, '');
                    const titlePrefix = compactTitle.slice(0, Math.min(12, compactTitle.length));
                    const rows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"]'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text && item.text.includes('已完成'))
                      .filter(item => !expectedAmount || item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes('￥' + expectedAmount) || expectedAmount === '70');
                    const byTitle = rows.find(item => {
                      const compact = item.text.replace(/\\s+/g, '');
                      return compactTitle && (compact.includes(compactTitle) || compact.includes(titlePrefix));
                    });
                    const candidate = byTitle || rows[0];
                    return candidate?.text || '';
                }""",
                {"orderTitle": order_title, "expectedAmount": expected_amount},
            )
        )
        if not row_text:
            return ""
        assert any(key in row_text for key in ["已完成", "待结算", "结算", "¥", "￥"]), (
            f"打手应用-我的收入最新完成行不符合收入记录特征；order_title={order_title}; row_text={row_text[:1500]}"
        )
        return row_text

    def verify_completed_player_order_as_income_fallback(
        self,
        source_order_no: str,
        order_title: str,
        expected_amount: str = "70",
        menu_error: str = "",
    ) -> str:
        frame = self.open_player_my_orders(source_order_no=source_order_no)
        text = self.search_player_my_order_list(frame, source_order_no, order_title)
        row_text = self.extract_frame_order_row_text(frame, source_order_no, order_title)
        status_text = row_text or text
        assert source_order_no in text or order_title in text or source_order_no in status_text or order_title in status_text, (
            f"打手应用-我的收入菜单不可用，且我的订单未找到目标已完成订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; menu_error={menu_error[:800]}; text={text[:1500]}"
        )
        assert any(key in status_text for key in ["已完成", "完成", "待结算", "结算", "完:"]), (
            f"打手应用-我的收入菜单不可用，回查我的订单但未看到已完成/待结算状态；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; menu_error={menu_error[:800]}; text={text[:1500]}"
        )
        amount_candidates = self.money_text_candidates(expected_amount)
        if expected_amount:
            assert any(amount in status_text or amount in text for amount in amount_candidates), (
                f"打手应用-我的收入菜单不可用，回查我的订单但未看到预期金额；source_order_no={source_order_no}; "
                f"expected_amount={expected_amount}; amount_candidates={amount_candidates}; "
                f"row_text={row_text[:1500]}; text={text[:1500]}"
            )
        return f"[我的收入菜单缺失，已回退校验我的订单]\n{status_text[:1500]}"

    def verify_player_income_settlement_completed(
        self,
        source_order_no: str,
        order_title: str,
        expected_amount: str = "70",
        expected_total_amount: str = "",
    ) -> str:
        frame = self.open_player_income()
        initial_text = frame.locator("body").inner_text(timeout=5000)
        self.click_player_income_order_tab(frame)
        self.reset_search_if_possible(frame)
        self.search_order_if_possible(frame, source_order_no or order_title)
        text = frame.locator("body").inner_text(timeout=5000)
        row_text = self.player_income_order_row_text(frame, source_order_no, order_title, expected_amount)
        status_text = row_text or text
        expected_amount_texts = self.money_text_candidates(expected_amount)
        expected_total_texts = self.money_text_candidates(expected_total_amount or expected_amount)
        expected_bill_value = self.money_decimal(expected_total_amount or expected_amount)
        signed_income_values = self.signed_income_values(initial_text)
        assert "已完成" in status_text, (
            f"丸友集给打手完成结算后，打手我的收入订单状态未同步为已完成；"
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}; text={text[:1500]}"
        )
        assert "已结算" in status_text or any(key in status_text for key in ["已打款", "结算完成", "已到账", "已付款"]), (
            f"丸友集给打手完成结算后，打手我的收入结算状态未同步为已结算；"
            f"source_order_no={source_order_no}; row_text={row_text[:1500]}; text={text[:1500]}"
        )
        assert any(amount in status_text for amount in expected_amount_texts), (
            f"打手我的收入订单金额不等于本次订单结算金额；"
            f"source_order_no={source_order_no}; expected_amount={expected_amount}; "
            f"amount_candidates={expected_amount_texts}; row_text={row_text[:1500]}; text={text[:1500]}"
        )
        if signed_income_values:
            actual_net_income = sum(signed_income_values, Decimal("0"))
            total_income_value = self.extract_player_total_income_value(initial_text)
            assert total_income_value is None or total_income_value == actual_net_income, (
                f"打手我的收入总收入金额不等于页面正负收入明细净额；"
                f"source_order_no={source_order_no}; total_income={total_income_value}; "
                f"actual_net_income={actual_net_income}; signed_values={signed_income_values}; "
                f"initial_text={initial_text[:1500]}"
            )
        assert any(amount in status_text or amount in initial_text for amount in expected_total_texts), (
            f"打手我的收入未看到本次账单结算金额；"
            f"source_order_no={source_order_no}; expected_total_amount={expected_total_amount or expected_amount}; "
            f"amount_candidates={expected_total_texts}; text={text[:1500]}; initial_text={initial_text[:1500]}"
        )
        if expected_bill_value is not None:
            assert any(amount in status_text or amount in initial_text for amount in expected_total_texts), (
                f"打手我的收入本次账单金额不等于预期结算金额；"
                f"source_order_no={source_order_no}; expected_bill_value={expected_bill_value}; "
                f"amount_candidates={expected_total_texts}; row_text={row_text[:1500]}; initial_text={initial_text[:1500]}"
            )
        return status_text

    def player_income_order_row_text(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        expected_amount: str,
    ) -> str:
        return str(
            frame.evaluate(
                """({ sourceOrderNo, orderTitle, expectedAmount }) => {
                    const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0);
                    const titleSuffix = orderTitle && orderTitle.length > 8 ? orderTitle.slice(-8) : orderTitle;
                    const row = rows.find(item => sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                      rows.find(item => orderTitle && item.text.includes(orderTitle)) ||
                      rows.find(item => titleSuffix && item.text.includes(titleSuffix)) ||
                      rows.find(item =>
                        item.text.includes('已完成') &&
                        item.text.includes('已结算') &&
                        (item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes(`￥${expectedAmount}`))
                      );
                    return row ? row.text : '';
                }""",
                {"sourceOrderNo": source_order_no, "orderTitle": order_title, "expectedAmount": expected_amount},
            )
        )

    def money_text_candidates(self, amount: str) -> list[str]:
        raw = str(amount).strip().replace("¥", "").replace("￥", "").replace(",", "")
        candidates = {raw, f"¥{raw}", f"￥{raw}"}
        try:
            value = float(raw)
            if value.is_integer():
                integer = str(int(value))
                candidates.update({integer, f"¥{integer}", f"￥{integer}"})
            fixed = f"{value:.2f}"
            candidates.update({fixed, f"¥{fixed}", f"￥{fixed}"})
        except ValueError:
            pass
        return [item for item in candidates if item]

    def money_decimal(self, amount: str) -> Decimal | None:
        raw = str(amount).strip().replace("¥", "").replace("￥", "").replace(",", "")
        if not raw:
            return None
        try:
            return Decimal(raw).quantize(Decimal("0.01"))
        except InvalidOperation:
            return None

    def extract_player_total_income_value(self, text: str) -> Decimal | None:
        if not text:
            return None
        match = re.search(r"总收入\s*\(元\)[\s\S]{0,80}?([0-9]+(?:\.[0-9]{1,2})?)", text.replace(",", ""))
        if not match:
            return None
        return self.money_decimal(match.group(1))

    def signed_income_values(self, text: str) -> list[Decimal]:
        values: list[Decimal] = []
        if not text:
            return values
        normalized = text.replace(",", "")
        for sign, amount in re.findall(r"(?<!\d)([+-])[ \u00a0]*[¥￥]?[ \u00a0]*([0-9]+(?:\.[0-9]{1,2})?)", normalized):
            try:
                value = Decimal(amount).quantize(Decimal("0.01"))
            except InvalidOperation:
                continue
            values.append(value if sign == "+" else -value)
        return values

    def ensure_player_payment_account_ready(
        self,
        *,
        account_name: str,
        account_phone: str,
        payment_account: str,
        bank_name: str = "E2E测试银行",
        id_card: str = "110101199001011234",
        verify_one_click: bool = True,
    ) -> str:
        frame = self.open_player_income()
        text = frame.locator("body").inner_text(timeout=5000)
        if self.player_payment_account_ready(text):
            if verify_one_click:
                self.verify_player_one_click_settlement_not_blocked_by_account(frame)
            return text

        clicked = self.open_player_payment_account_editor(frame)
        assert clicked, f"打手应用-我的收入未找到打款账户完善/修改入口；text={text[:1500]}"
        self.fill_player_payment_account_form(
            frame,
            {
                "收款人": account_name,
                "姓名": account_name,
                "手机号": account_phone,
                "手机号码": account_phone,
                "收款账户": payment_account,
                "打款账户": payment_account,
                "支付宝": payment_account,
                "银行卡": payment_account,
                "银行卡号": payment_account,
                "开户行": bank_name,
                "银行": bank_name,
                "身份证": id_card,
            },
        )
        saved = self.click_latest_confirm_primary_button(frame) or self.click_optional_text(frame, "保 存") or self.click_optional_text(frame, "保存")
        assert saved, f"打手打款账户弹窗未找到保存/确认按钮；text={self.visible_modal_text(frame)[:1500]}"
        self.page.wait_for_timeout(3000)
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert self.player_payment_account_ready(final_text), (
            f"保存打款账户后仍未看到已完善/可结算状态；text={final_text[:1500]}"
        )
        if verify_one_click:
            self.verify_player_one_click_settlement_not_blocked_by_account(frame)
        return final_text

    def assert_player_payment_account_ready_on_income_page(self, frame: Frame) -> str:
        text = frame.locator("body").inner_text(timeout=5000)
        assert "打款账户信息" in text, f"打手应用-我的收入未看到打款账户信息区域；text={text[:1500]}"
        assert "已完善" in text and not any(key in text for key in ["未完善", "去完善", "请完善"]), (
            f"打手一键结算前打款账户信息不是已完善状态；text={text[:1500]}"
        )
        return text

    def player_payment_account_ready(self, text: str) -> bool:
        if any(key in text for key in ["未完善", "去完善", "请完善", "完善打款账户", "完善账户信息"]):
            return False
        return any(key in text for key in ["已完善", "修改账户信息", "打款账户信息", "收款账户", "可结算", "一键结算"])

    def open_player_payment_account_editor(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """texts => {
                const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .filter(item => texts.some(text => item.text === text || item.text.includes(text)))
                  .sort((a, b) => {
                    const aExact = texts.some(text => a.text === text) ? 0 : 1;
                    const bExact = texts.some(text => b.text === text) ? 0 : 1;
                    return aExact - bExact || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.click();
                return true;
            }""",
            ["完善打款账户", "完善账户信息", "修改账户信息", "新增打款账户", "打款账户信息", "收款账户"],
        )
        self.page.wait_for_timeout(1500)
        return bool(clicked)

    def fill_player_payment_account_form(self, frame: Frame, defaults: dict[str, str]) -> None:
        frame.evaluate(
            """defaults => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const controls = Array.from(root.querySelectorAll('input, textarea'))
                  .map(el => {
                    const r = el.getBoundingClientRect();
                    const placeholder = el.getAttribute('placeholder') || '';
                    let label = '';
                    for (let node = el.parentElement; node && node !== root; node = node.parentElement) {
                      const text = (node.innerText || node.textContent || '').trim();
                      if (text && text.length < 80) {
                        label = text;
                        break;
                      }
                    }
                    return { el, r, placeholder, label };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => !item.el.disabled && !item.el.readOnly);
                const used = new Set();
                const setValue = (el, value) => {
                  const proto = el.tagName === 'TEXTAREA'
                    ? window.HTMLTextAreaElement.prototype
                    : window.HTMLInputElement.prototype;
                  const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                  setter.call(el, value);
                  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                  el.dispatchEvent(new Event('change', { bubbles: true }));
                  el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                };
                for (const [key, value] of Object.entries(defaults)) {
                  const target = controls.find(item =>
                    !used.has(item.el) &&
                    (item.placeholder.includes(key) || item.label.includes(key))
                  );
                  if (!target) continue;
                  setValue(target.el, value);
                  used.add(target.el);
                }
            }""",
            defaults,
        )
        self.page.wait_for_timeout(600)

    def verify_player_one_click_settlement_not_blocked_by_account(self, frame: Frame) -> str:
        before_text = self.assert_player_payment_account_ready_on_income_page(frame)
        clicked = self.click_player_one_click_settlement_button(frame)
        if not clicked:
            assert "未完善" not in before_text and "请完善" not in before_text, (
                f"打手我的收入未找到一键结算入口，且账户状态疑似未完善；text={before_text[:1500]}"
            )
            return before_text
        self.page.wait_for_timeout(1800)
        text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert not any(key in text for key in ["未完善", "请完善打款账户", "请先完善", "打款账户信息未完善"]), (
            f"点击一键结算后仍被打款账户未完善拦截；text={text[:1500]}"
        )
        self.close_current_modal(frame)
        return text

    def apply_player_settlement_for_order(
        self,
        source_order_no: str,
        order_title: str,
        expected_amount: str = "70",
        account_name: str = "E2E打手",
        account_phone: str = "",
        payment_account: str = "",
        bank_name: str = "E2E测试银行",
        id_card: str = "110101199001011234",
    ) -> str:
        self.open_player_settlement_page(
            source_order_no,
            order_title,
            expected_amount=expected_amount,
            account_name=account_name,
            account_phone=account_phone,
            payment_account=payment_account,
            bank_name=bank_name,
            id_card=id_card,
        )
        frame = self.best_wanyouji_frame(["提交账单", "结算申请", "账单", expected_amount])
        return self.submit_player_settlement_bill(frame, expected_amount)

    def open_player_settlement_page(
        self,
        source_order_no: str,
        order_title: str,
        expected_amount: str = "70",
        account_name: str = "E2E打手",
        account_phone: str = "",
        payment_account: str = "",
        bank_name: str = "E2E测试银行",
        id_card: str = "110101199001011234",
    ) -> str:
        frame = self.open_player_income()
        income_text = frame.locator("body").inner_text(timeout=5000)
        if not self.player_payment_account_ready(income_text):
            self.ensure_player_payment_account_ready(
                account_name=account_name,
                account_phone=account_phone,
                payment_account=payment_account or f"e2e{account_phone or 'player'}@example.com",
                bank_name=bank_name,
                id_card=id_card,
                verify_one_click=False,
            )
            frame = self.open_player_income()
        self.assert_player_payment_account_ready_on_income_page(frame)
        self.click_player_income_order_tab(frame)
        text = self.search_player_income_order_for_settlement(frame, source_order_no, order_title, expected_amount)
        assert expected_amount in text or f"¥{expected_amount}" in text or f"￥{expected_amount}" in text, (
            f"打手应用-我的收入找到订单但未看到可结算金额；source_order_no={source_order_no}; "
            f"expected_amount={expected_amount}; text={text[:1500]}"
        )
        selected = self.select_player_income_order_for_settlement(frame, source_order_no, order_title, expected_amount)
        assert selected, (
            f"打手应用-我的收入未能勾选待结算订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        clicked = self.click_player_one_click_settlement_button(frame)
        assert clicked, f"打手应用-我的收入未找到一键结算按钮；text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        self.page.wait_for_timeout(1800)
        settlement_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert not any(key in settlement_text for key in ["未完善", "请完善打款账户", "请先完善", "打款账户信息未完善"]), (
            f"点击一键结算后仍被打款账户未完善拦截；text={settlement_text[:1500]}"
        )
        assert any(key in settlement_text for key in ["结算", "账单", "预计结算", "确认", "提交", "收款", "打款账户"]), (
            f"点击一键结算后未进入结算页面/确认页；text={settlement_text[:1500]}"
        )
        assert expected_amount in settlement_text or f"¥{expected_amount}" in settlement_text or f"￥{expected_amount}" in settlement_text, (
            f"一键结算页面未看到预期结算金额；expected={expected_amount}; text={settlement_text[:1500]}"
        )
        return settlement_text

    def submit_player_settlement_bill(self, frame: Frame, expected_amount: str = "70") -> str:
        before_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert expected_amount in before_text or f"¥{expected_amount}" in before_text or f"￥{expected_amount}" in before_text, (
            f"提交账单前未看到预期结算金额；expected={expected_amount}; text={before_text[:1500]}"
        )
        clicked = self.click_player_submit_bill_button(frame)
        assert clicked, f"结算申请确认弹框/页面未找到提交账单按钮；text={before_text[:1500]}"
        self.page.wait_for_timeout(2500)
        result_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert any(
            key in result_text
            for key in ["成功", "提交成功", "已提交", "已申请", "待审核", "结算中", "账单", "账单号", "申请时间", "待打款"]
        ), f"提交账单后未看到成功或账单状态；text={result_text[:1500]}"
        return result_text

    def click_player_submit_bill_button(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const scopes = roots.length ? [roots[roots.length - 1], document] : [document];
                const labels = ['提交账单', '确认提交', '提交申请', '确认结算', '提交'];
                for (const scope of scopes) {
                  const buttons = Array.from(scope.querySelectorAll('button, a, [role="button"]'))
                    .map(el => ({
                      el,
                      text: (el.innerText || el.textContent || '').trim(),
                      r: el.getBoundingClientRect(),
                      disabled: el.disabled || el.getAttribute('aria-disabled') === 'true'
                    }))
                    .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                  for (const label of labels) {
                    const target = buttons.find(item => item.text === label) ||
                      buttons.find(item => item.text.replace(/\\s+/g, '') === label) ||
                      buttons.find(item => item.text.includes(label));
                    if (target) {
                      target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                      target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                      target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                      target.el.click();
                      return true;
                    }
                  }
                }
                return false;
            }"""
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def click_player_one_click_settlement_button(self, frame: Frame) -> bool:
        clicked = frame.evaluate(
            """() => {
                const buttons = Array.from(document.querySelectorAll('button, a, [role="button"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: el.disabled }))
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                  .filter(item => item.text === '一键结算' || item.text.includes('一键结算'))
                  .sort((a, b) => {
                    if (a.text === '一键结算' && b.text !== '一键结算') return -1;
                    if (b.text === '一键结算' && a.text !== '一键结算') return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = buttons[0];
                if (!target) return false;
                target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                target.el.click();
                return true;
            }"""
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def search_player_income_order_for_settlement(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        expected_amount: str,
    ) -> str:
        last_text = ""
        keywords = [source_order_no] if os.getenv("E2E_PLAYER_SETTLEMENT_FAST_SEARCH", "").lower() in {"1", "true", "yes"} else [
            source_order_no,
            order_title,
            order_title[-10:],
        ]
        for keyword in keywords:
            self.reset_search_if_possible(frame)
            self.search_order_if_possible(frame, keyword)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if source_order_no in last_text or order_title in last_text:
                return last_text
            if expected_amount in last_text and any(key in last_text for key in ["待结算", "可结算", "已完成"]):
                return last_text
        return last_text

    def select_player_income_order_for_settlement(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        expected_amount: str,
    ) -> bool:
        suffix = order_title[-6:] if len(order_title) >= 6 else order_title
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, suffix, expectedAmount }) => {
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = rows.find(item => item.text.includes(sourceOrderNo) || item.text.includes(orderTitle)) ||
                  rows.find(item => item.text.includes(suffix) && (item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes('￥' + expectedAmount))) ||
                  rows.find(item => (item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes('￥' + expectedAmount)) && ['待结算', '可结算', '已完成'].some(key => item.text.includes(key)));
                if (!row) return false;
                const checkbox = row.row.querySelector('input[type="checkbox"]') ||
                  row.row.querySelector('.ant-checkbox-input') ||
                  row.row.querySelector('.ant-checkbox, [role="checkbox"]');
                if (checkbox) {
                  const clickable = checkbox.closest('label, .ant-checkbox-wrapper, .ant-checkbox') || checkbox;
                  clickable.click();
                  return true;
                }
                row.row.click();
                return true;
            }""",
            {
                "sourceOrderNo": source_order_no,
                "orderTitle": order_title,
                "suffix": suffix,
                "expectedAmount": expected_amount,
            },
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def verify_owner_settlement_record_for_order(
        self,
        source_order_no: str,
        order_title: str,
        expected_player_amount: str = "70",
    ) -> str:
        attempts: list[str] = []
        page_names = ["结算明细-丸友集", "结算账单-丸友集"]
        settlement_batch_no = ""
        verified: dict[str, str] = {}
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        clicked_parent = self.click_sidebar_text_scrolled("结算中心", right_edge=True)
        assert clicked_parent, (
            f"丸友集主账号未找到结算中心菜单；"
            f"text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
        )
        self.page.wait_for_timeout(1500)

        for page_name in page_names:
            page_text = ""
            try:
                clicked = self.click_sidebar_leaf_precise(page_name) or self.click_sidebar_text_scrolled(page_name)
                assert clicked, (
                    f"丸友集主账号未找到结算菜单：结算中心 > {page_name}；"
                    f"text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
                )
                self.page.wait_for_timeout(3500)
                frame = self.owner_settlement_frame(page_name)
                keywords = [
                    settlement_batch_no,
                    source_order_no,
                    order_title,
                    source_order_no[-10:],
                    order_title[-10:],
                ]
                keywords = [keyword for keyword in keywords if keyword]
                for keyword in keywords:
                    self.reset_search_if_possible(frame)
                    self.search_order_if_possible(frame, keyword)
                    text = frame.locator("body").inner_text(timeout=5000)
                    attempts.append(f"{page_name} / {keyword}: {text[:1000]}")
                    page_text = text
                    try:
                        self.assert_owner_settlement_text(
                            text,
                            source_order_no=source_order_no,
                            page_name=page_name,
                            expected_player_amount=expected_player_amount,
                            expected_batch_no=settlement_batch_no if page_name == "结算账单-丸友集" else "",
                        )
                        verified[page_name] = text
                        if page_name == "结算明细-丸友集":
                            settlement_batch_no = self.extract_owner_settlement_batch_no(text)
                        break
                    except AssertionError as exc:
                        attempts.append(f"{page_name} / {keyword} validation failed: {exc}")
                    if self.open_owner_settlement_detail_if_possible(frame, source_order_no, order_title, keyword):
                        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
                        attempts.append(f"{page_name} / {keyword} / detail: {detail_text[:1000]}")
                        try:
                            self.assert_owner_settlement_text(
                                detail_text,
                                source_order_no=source_order_no,
                                page_name=page_name,
                                expected_player_amount=expected_player_amount,
                                expected_batch_no=settlement_batch_no if page_name == "结算账单-丸友集" else "",
                            )
                            verified[page_name] = detail_text
                            if page_name == "结算明细-丸友集":
                                settlement_batch_no = self.extract_owner_settlement_batch_no(detail_text)
                            self.close_current_modal(frame)
                            break
                        except AssertionError as exc:
                            attempts.append(f"{page_name} / {keyword} / detail validation failed: {exc}")
                        self.close_current_modal(frame)
                if page_name not in verified:
                    attempts.append(f"{page_name} no match after searches; text={page_text[:1000]}")
            except Exception as exc:
                attempts.append(f"{page_name} failed: {exc}")
                continue
        missing = [name for name in page_names if name not in verified]
        if missing:
            raise AssertionError(
                f"丸友集主账号结算中心缺少记录校验结果；missing={missing}; "
                f"source_order_no={source_order_no}; order_title={order_title}; attempts={attempts[-8:]}"
            )
        return "\n\n--- 结算中心校验分隔 ---\n\n".join(f"[{name}]\n{text}" for name, text in verified.items())

    def owner_settlement_frame(self, page_name: str) -> Frame:
        last_urls: list[str] = []
        for _ in range(12):
            candidates: list[Frame] = []
            last_urls = [frame.url for frame in self.page.frames]
            for frame in self.page.frames:
                if "test-ledger" not in frame.url:
                    continue
                try:
                    text = frame.locator("body").inner_text(timeout=2000)
                except Exception:
                    text = ""
                if page_name == "结算账单-丸友集":
                    if "申请/批次号" in text and ("申请金额合计" in text or "账单状态" in text or "发单商户" in text):
                        candidates.append(frame)
                elif "业务单号" in text and "完结时间" in text and "金额" in text:
                    candidates.append(frame)
            if candidates:
                return candidates[-1]
            self.page.wait_for_timeout(1000)
        raise AssertionError(f"未找到丸友集结算中心账本 iframe；page_name={page_name}; frames={last_urls}")

    def assert_owner_settlement_text(
        self,
        text: str,
        *,
        source_order_no: str,
        page_name: str,
        expected_player_amount: str,
        expected_batch_no: str = "",
    ) -> None:
        status_keys = (
            ["待确认打款信息", "确认打款信息", "申请金额合计", "实际结算金额"]
            if page_name == "结算账单-丸友集"
            else ["已完成", "完结时间", "待结算", "待确认打款信息", "结算", "完成", "待打款", "待付款", "待支付"]
        )
        assert any(key in text for key in status_keys), (
            f"丸友集主账号 {page_name} 找到订单但未看到主单/结算状态；"
            f"source_order_no={source_order_no}; text={text[:1500]}"
        )
        assert expected_player_amount in text or f"¥{expected_player_amount}" in text or f"￥{expected_player_amount}" in text, (
            f"丸友集主账号 {page_name} 找到订单但未看到需给打手支付金额；"
            f"source_order_no={source_order_no}; expected={expected_player_amount}; text={text[:1500]}"
        )
        if expected_batch_no:
            assert expected_batch_no in text, (
                f"丸友集主账号 {page_name} 未找到结算明细关联批次；"
                f"source_order_no={source_order_no}; expected_batch_no={expected_batch_no}; text={text[:1500]}"
            )

    def extract_owner_settlement_batch_no(self, text: str) -> str:
        match = re.search(r"\bRC\d{8,}\b", text)
        assert match, f"丸友集主账号结算明细未找到关联批次号；text={text[:1500]}"
        return match.group(0)

    def verify_workshop_employee_settlement_summary_detail(
        self,
        source_order_no: str,
        order_title: str,
        expected_player_amount: str,
        player_name: str = "",
        settlement_batch_no: str = "",
    ) -> str:
        frame = self.open_workshop_finance_settlement_list()
        self.open_employee_settlement_summary(frame)
        text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in text for key in ["查看明细", "详情", "打手姓名", "待结算金额", "关联账单数"]), (
            f"员工结算汇总列表未加载出可查看详情的汇总行；text={text[:1500]}"
        )
        clicked = self.click_employee_settlement_summary_detail(frame, expected_player_amount, player_name=player_name)
        assert clicked, f"员工结算汇总列表未找到详情入口；text={text[:1500]}"
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        self.assert_workshop_finance_settlement_detail_text(
            detail_text,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_player_amount=expected_player_amount,
            settlement_batch_no=settlement_batch_no,
        )
        self.close_current_modal(frame)
        return detail_text

    def initiate_employee_settlement_bill_from_pending(
        self,
        source_order_no: str,
        order_title: str,
        expected_player_amount: str,
        player_name: str = "",
    ) -> str:
        frame = self.open_workshop_finance_settlement_list()
        self.open_employee_settlement_pending_tab(frame)
        if source_order_no:
            self.search_order_if_possible(frame, source_order_no)
        text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in text for key in ["账单待发起", "账单代发起", "待发起", "代发起", "生成账单", "发起账单"]), (
            f"未进入员工结算汇总-账单待发起；text={text[:1500]}"
        )
        assert not any(key in text for key in ["暂无数据", "暂无待发起", "没有数据"]), (
            "员工结算汇总-账单待发起暂无可生成的打手账单；"
            f"请先完成打手订单验收并生成收入。text={text[:1500]}"
        )
        before_related_count = self.employee_summary_related_bill_count(frame, player_name)
        clicked = self.click_employee_pending_bill_action(
            frame,
            source_order_no=source_order_no,
            order_title=order_title,
            expected_player_amount=expected_player_amount,
            player_name=player_name,
        )
        assert clicked, f"账单待发起列表未找到目标行的生成/发起账单入口；text={text[:1500]}"
        self.page.wait_for_timeout(1500)
        modal_text = self.visible_modal_text(frame)
        if modal_text:
            assert any(key in modal_text for key in ["生成账单", "发起账单", "确认", "结算", "金额", expected_player_amount]), (
                f"点击生成账单后弹窗内容不符合预期；text={modal_text[:1500]}"
            )
            assert not self.employee_bill_modal_has_no_settle_items(modal_text), (
                "员工结算汇总-账单待发起弹窗暂无可结算订单；"
                f"请先新增一笔丸友集已验收且未结算的打手收入。text={modal_text[:1500]}"
            )
            assert self.click_modal_button(frame, ["提交账单", "确 定", "确认", "提交", "生成账单", "发起账单"]), (
                f"生成账单弹窗未找到确认/提交按钮；text={modal_text[:1500]}"
            )
        self.page.wait_for_timeout(3000)
        final_text = frame.locator("body").inner_text(timeout=5000)
        after_related_count = self.employee_summary_related_bill_count(frame, player_name)
        related_count_increased = (
            before_related_count is not None
            and after_related_count is not None
            and after_related_count > before_related_count
        )
        assert related_count_increased or any(
            key in final_text for key in ["生成成功", "发起成功", "操作成功", "结算账单", "账单已发起", "账单列表", "待审核"]
        ), (
            f"账单待发起点击确认后未看到生成成功或账单状态变化；text={final_text[:1500]}"
        )
        return final_text

    def employee_bill_modal_has_no_settle_items(self, modal_text: str) -> bool:
        normalized = re.sub(r"\s+", "", modal_text)
        return (
            "订单0笔" in normalized
            or "订单：0笔" in normalized
            or ("暂无数据" in modal_text and ("结算金额\n+¥0.00" in modal_text or "结算金额+¥0.00" in normalized))
            or "结算金额+￥0.00" in normalized
        )

    def employee_summary_related_bill_count(self, frame: Frame, player_name: str = "") -> int | None:
        value = frame.evaluate(
            """playerName => {
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = (playerName && rows.find(item => item.text.includes(playerName))) || rows[0];
                if (!row) return null;
                const parts = row.text.split(/\\s+/).filter(Boolean);
                const numeric = parts.map(part => {
                  const match = part.match(/^\\d+$/);
                  return match ? Number(part) : null;
                }).filter(value => value !== null);
                return numeric.length ? numeric[numeric.length - 1] : null;
            }""",
            player_name,
        )
        return int(value) if value is not None else None

    def open_employee_settlement_pending_tab(self, frame: Frame) -> None:
        self.open_employee_settlement_summary(frame)
        for text in ["账单待发起", "账单代发起", "待发起账单", "代发起账单", "待发起", "代发起"]:
            if self.click_optional_text(frame, text):
                break
        self.page.wait_for_timeout(2000)
        text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in text for key in ["账单待发起", "账单代发起", "待发起", "代发起", "生成账单", "发起账单"]), (
            f"未找到员工结算汇总-账单待发起页签；text={text[:1500]}"
        )

    def click_employee_pending_bill_action(
        self,
        frame: Frame,
        *,
        source_order_no: str,
        order_title: str,
        expected_player_amount: str,
        player_name: str = "",
    ) -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, expectedAmount, playerName }) => {
                const amountKeys = [expectedAmount, `¥${expectedAmount}`, `￥${expectedAmount}`, `${expectedAmount}.00`].filter(Boolean);
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = rows.find(item => sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                  rows.find(item => orderTitle && item.text.includes(orderTitle)) ||
                  rows.find(item => playerName && item.text.includes(playerName)) ||
                  rows.find(item => amountKeys.some(key => item.text.includes(key))) ||
                  rows.find(item => ['生成账单', '发起账单', '账单代发起', '账单待发起', '代发起', '待发起', '发起'].some(key => item.text.includes(key))) ||
                  rows[0];
                if (!row) return false;
                const nodes = Array.from(row.row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    const text = (el.innerText || el.textContent || '').trim();
                    const clickableText = (clickable.innerText || clickable.textContent || text).trim();
                    const r = el.getBoundingClientRect();
                    const disabled = Boolean(clickable.disabled) || clickable.getAttribute('aria-disabled') === 'true';
                    return { el, clickable, text, clickableText, r, disabled };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                const target = nodes.find(item =>
                  ['生成账单', '发起账单', '账单代发起', '账单待发起', '立即发起', '代发起', '待发起', '发起'].some(key => item.text === key || item.text.includes(key) || item.clickableText.includes(key))
                );
                if (!target) return false;
                target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                target.clickable.click();
                return true;
            }""",
            {
                "sourceOrderNo": source_order_no,
                "orderTitle": order_title,
                "expectedAmount": expected_player_amount,
                "playerName": player_name,
            },
        )
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def click_employee_settlement_summary_detail(self, frame: Frame, expected_player_amount: str, player_name: str = "") -> bool:
        clicked = frame.evaluate(
            """({ expectedAmount, playerName }) => {
                const amountKeys = [expectedAmount, `¥${expectedAmount}`, `￥${expectedAmount}`, `${expectedAmount}.00`];
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = (playerName && rows.find(item => item.text.includes(playerName))) ||
                  rows.find(item => amountKeys.some(key => item.text.includes(key))) ||
                  rows.find(item => item.text.includes('查看明细') || item.text.includes('详情')) ||
                  rows[0];
                if (!row) return false;
                const nodes = Array.from(row.row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const target = nodes.find(item => ['查看明细', '详情', '详细', '查看'].some(text => item.text === text || item.text.includes(text)));
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                clickable.click();
                return true;
            }""",
            {"expectedAmount": expected_player_amount, "playerName": player_name},
        )
        self.page.wait_for_timeout(1500)
        return bool(clicked)

    def open_employee_settlement_summary(self, frame: Frame) -> None:
        text = ""
        for _ in range(5):
            text = frame.locator("body").inner_text(timeout=5000)
            if any(key in text for key in ["员工结算汇总", "打手", "员工", "汇总", "结算金额", "详情"]):
                return
            self.click_optional_text(frame, "员工结算汇总") or self.click_optional_text(frame, "结算汇总")
            self.page.wait_for_timeout(1500)
        assert any(key in text for key in ["员工结算汇总", "打手", "员工", "汇总", "结算金额", "详情"]), (
            f"未进入工作室基础应用-财务结算列表-员工结算汇总；text={text[:1500]}"
        )

    def open_workshop_finance_settlement_list(self) -> Frame:
        frame = self.open_workshop_finance_settlement_shell()
        clicked_summary = (
            self.click_sidebar_leaf_strict("员工结算汇总")
            or self.click_sidebar_leaf_precise("员工结算汇总")
            or self.click_sidebar_text_scrolled("员工结算汇总")
        )
        assert clicked_summary, (
            f"未找到工作室基础应用-财务结算列表-员工结算汇总入口；"
            f"text={self.page.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        self.page.wait_for_timeout(3500)
        self.retry_if_app_load_timeout()
        try:
            return self.best_wanyouji_frame(["财务结算", "员工结算汇总", "结算汇总", "申请/批次号", "业务单号", "打款", "金额"])
        except AssertionError:
            self.open_collection_settlement_route()
            return self.collection_settlement_frame()

    def open_workshop_finance_settlement_shell(self) -> Frame | None:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.wait_for_enterprise_console_shell()
        try:
            self.click_sidebar_text("工作室基础应用", right_edge=True)
        except AssertionError:
            assert self.click_sidebar_visible_text("工作室基础应用", right_edge=True), (
                f"未找到工作室基础应用菜单；text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
            )
        self.page.wait_for_timeout(800)
        clicked = self.click_sidebar_leaf_strict("财务结算列表") or self.click_sidebar_leaf_precise("财务结算列表") or self.click_sidebar_text_scrolled("财务结算列表")
        assert clicked, (
            f"未找到工作室基础应用-财务结算列表入口；"
            f"text={self.page.locator('body').inner_text(timeout=5000)[:1200]}"
        )
        self.page.wait_for_timeout(1000)
        self.wait_for_wshop_login_ready()
        try:
            return self.workshop_finance_frame()
        except AssertionError:
            return None

    def set_wshop_iframe_url(self, url: str) -> None:
        self.page.evaluate(
            """url => {
                const frame = Array.from(document.querySelectorAll('iframe'))
                  .find(item => (item.src || '').includes('test-wshop'));
                if (frame) {
                  frame.src = url;
                  return;
                }
                window.location.href = url;
            }""",
            url,
        )

    def wait_for_wshop_login_ready(self, timeout_ms: int | None = None) -> bool:
        deadline = datetime.now().timestamp() + (timeout_ms or int(os.getenv("E2E_WSHOP_LOGIN_READY_TIMEOUT_MS", "12000"))) / 1000
        last_error = ""
        while datetime.now().timestamp() < deadline:
            for frame in self.wanyouji_business_frames():
                if "test-wshop" not in frame.url:
                    continue
                try:
                    has_token = frame.evaluate(
                        """() => {
                            const storages = [window.localStorage, window.sessionStorage];
                            return storages.some(storage => {
                                for (let index = 0; index < storage.length; index += 1) {
                                    const key = storage.key(index) || '';
                                    const value = storage.getItem(key) || '';
                                    if (/token/i.test(key) && value.length > 20) return true;
                                    if (value.startsWith('eyJ') && value.split('.').length >= 3) return true;
                                }
                                return false;
                            });
                        }"""
                    )
                    if has_token:
                        return True
                except Exception as exc:
                    last_error = str(exc)
            self.page.wait_for_timeout(500)
        print(f"[E2E] wait_for_wshop_login_ready timeout; last_error={last_error}")
        return False

    def open_collection_settlement_route(self) -> None:
        self.set_wshop_iframe_url("https://test-wshop.lianlianwz.com")
        self.page.wait_for_timeout(1000)
        self.wait_for_wshop_login_ready()
        self.set_wshop_iframe_url("https://test-wshop.lianlianwz.com/collectionSettlement")
        self.page.wait_for_timeout(5000)

    def collection_settlement_frame(self) -> Frame:
        for frame in self.wanyouji_business_frames():
            if "collectionSettlement" in frame.url:
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到财务结算列表 iframe；frames={urls}")

    def open_workshop_settlement_bill_list(self) -> Frame:
        try:
            frame = self.open_workshop_finance_settlement_shell()
        except AssertionError:
            frame = None

        if frame is not None and self.ensure_workshop_settlement_bill_tab(frame):
            return self.workshop_finance_frame()

        for _ in range(4):
            self.click_sidebar_visible_text("工作室基础应用", right_edge=True) or self.click_sidebar_text_scrolled(
                "工作室基础应用", right_edge=True
            )
            self.page.wait_for_timeout(500)
            clicked_bill = self.click_sidebar_menu_item_exact("结算账单列表") or self.click_sidebar_leaf_strict(
                "结算账单列表"
            ) or self.click_sidebar_leaf_precise("结算账单列表") or self.click_sidebar_text_scrolled("结算账单列表")
            if not clicked_bill:
                self.click_sidebar_visible_text("财务结算列表", right_edge=True) or self.click_sidebar_text_scrolled(
                    "财务结算列表", right_edge=True
                )
                self.page.wait_for_timeout(600)
                clicked_bill = self.click_sidebar_menu_item_exact("结算账单列表") or self.click_sidebar_leaf_strict(
                    "结算账单列表"
                ) or self.click_sidebar_leaf_precise("结算账单列表") or self.click_sidebar_text_scrolled("结算账单列表")
            self.page.wait_for_timeout(3500)
            self.retry_if_app_load_timeout()
            try:
                frame = self.workshop_finance_frame()
            except AssertionError:
                try:
                    workbench = self.best_wanyouji_frame(["工作台", "工作室", "财务结算列表", "工作室基础应用"])
                except AssertionError:
                    continue
                self.click_frame_action_text(workbench, "财务结算列表") or self.click_frame_tab_text(
                    workbench, "财务结算列表"
                )
                self.page.wait_for_timeout(3000)
                try:
                    frame = self.workshop_finance_frame()
                except AssertionError:
                    continue
            self.click_frame_action_text(frame, "结算账单列表") or self.click_frame_tab_text(frame, "结算账单列表")
            self.page.wait_for_timeout(2000)
            try:
                frame = self.workshop_finance_frame()
            except AssertionError:
                continue
            if self.ensure_workshop_settlement_bill_tab(frame):
                return self.workshop_finance_frame()

        frame = self.workshop_finance_frame()
        text = frame.locator("body").inner_text(timeout=5000)
        assert False, f"未进入工作室基础应用-财务结算列表-结算账单列表；text={text[:1500]}"

    def ensure_workshop_settlement_bill_tab(self, frame: Frame) -> bool:
        bill_keys = ["线上打款配置", "账单号", "完成结算", "预计结算金额", "实际结算金额"]
        for _ in range(6):
            text = frame.locator("body").inner_text(timeout=5000)
            if any(key in text for key in bill_keys):
                self.prepare_workshop_settlement_bill_list(frame, text)
                return True
            clicked = (
                self.click_frame_action_text(frame, "结算账单列表")
                or self.click_frame_tab_text(frame, "结算账单列表")
                or self.click_sidebar_leaf_strict("结算账单列表")
                or self.click_sidebar_leaf_precise("结算账单列表")
                or self.click_sidebar_text_scrolled("结算账单列表")
            )
            self.page.wait_for_timeout(2000 if clicked else 1000)
            try:
                frame = self.workshop_finance_frame()
            except AssertionError:
                pass
        text = frame.locator("body").inner_text(timeout=5000)
        if any(key in text for key in bill_keys):
            self.prepare_workshop_settlement_bill_list(frame, text)
            return True
        return False

    def prepare_workshop_settlement_bill_list(self, frame: Frame, text: str = "") -> None:
        current_text = text or frame.locator("body").inner_text(timeout=5000)
        if "No data" not in current_text and "暂无数据" not in current_text:
            return
        self.click_frame_tab_text(frame, "待审核")
        self.page.wait_for_timeout(1500)
        current_text = frame.locator("body").inner_text(timeout=5000)
        if "No data" not in current_text and "暂无数据" not in current_text:
            return
        self.click_optional_text(frame, "重 置") or self.click_optional_text(frame, "重置")
        self.page.wait_for_timeout(800)
        self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")
        self.page.wait_for_timeout(2500)

    def click_frame_tab_text(self, frame: Frame, text: str) -> bool:
        clicked = frame.evaluate(
            """label => {
                const nodes = Array.from(document.querySelectorAll('.ant-tabs-tab, [role="tab"], button, a, span, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === label || item.text.replace(/\\s+/g, '') === label);
                const target = nodes[0];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="tab"], [role="button"], .ant-tabs-tab') || target.el;
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.click();
                return true;
            }""",
            text,
        )
        return bool(clicked)

    def workshop_finance_frame(self) -> Frame:
        for frame in self.wanyouji_business_frames():
            if any(key in frame.url for key in ["financialConfig", "billManage", "collectionSettlement"]):
                return frame
        for frame in self.wanyouji_business_frames():
            try:
                text = frame.locator("body").inner_text(timeout=1000)
            except Exception:
                continue
            if any(key in text for key in ["财务结算", "结算账单", "员工结算汇总", "线上打款配置"]):
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到财务结算 iframe；frames={urls}")

    def ensure_wanyouji_online_payment_config_ready(self) -> dict[str, str | bool]:
        frame = self.open_workshop_settlement_bill_list()
        text = frame.locator("body").inner_text(timeout=5000)
        assert ("结算账单" in text or "账单号" in text) and "线上打款配置" in text, (
            f"未进入工作室基础应用-财务结算列表-结算账单列表；text={text[:1500]}"
        )
        assert self.click_frame_action_text(frame, "线上打款配置"), f"结算账单列表未找到线上打款配置入口；text={text[:1500]}"
        self.page.wait_for_timeout(2000)
        modal_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert "交易应用" in modal_text and "交易通道" in modal_text, (
            f"线上打款配置弹窗未出现交易应用/交易通道字段；text={modal_text[:1500]}"
        )

        result: dict[str, str | bool] = {}
        app_options = self.open_modal_select_dropdown_options(frame, ["交易应用"], fallback_index=0)
        if not app_options:
            modal_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
            if (
                "未绑定" not in modal_text
                and "请选择交易应用" not in modal_text
                and "请先选择交易应用" not in modal_text
                and "交易应用" in modal_text
                and "交易通道" in modal_text
            ):
                result["already_configured"] = True
                result["final_text"] = modal_text[:1000]
                self.click_frame_action_text(frame, "确 认") or self.click_frame_action_text(frame, "确认")
                self.page.wait_for_timeout(1000)
                return result
            result["missing_trade_app_options"] = True
            raise AssertionError(
                "线上打款配置-交易应用下拉已展开但无可选项；请使用当前丸友集账号先完成B-SETTLE-PAYCENTER-001的支付中心交易应用/交易通道/账号配置前置，再继续本用例。"
            )
        result["missing_trade_app_options"] = False
        result["selected_app"] = self.select_first_open_dropdown_option(frame)
        assert result["selected_app"], f"交易应用下拉有选项但未能选中；options={app_options}"
        self.page.wait_for_timeout(1200)

        current_text = frame.locator("body").inner_text(timeout=5000)
        if "交易通道" in current_text and "去绑定" in current_text:
            channel_options_before = self.open_modal_select_dropdown_options(frame, ["交易通道"], fallback_index=1)
            if not channel_options_before:
                result["missing_trade_channel_options_before_bind"] = True
                assert self.click_frame_action_text(frame, "去绑定"), (
                    f"交易通道未绑定且未找到去绑定入口；text={current_text[:1500]}"
                )
                self.page.wait_for_timeout(2000)
                channel_options_after = self.open_modal_select_dropdown_options(frame, ["交易通道"], fallback_index=1)
                if not channel_options_after:
                    raise AssertionError(
                        "交易通道点击去绑定后，下拉仍无可选项；请使用当前丸友集账号先完成B-SETTLE-PAYCENTER-001的支付中心交易通道/账号配置前置，再继续本用例。"
                    )
                result["selected_channel"] = self.select_first_open_dropdown_option(frame)
            else:
                result["missing_trade_channel_options_before_bind"] = False
                result["selected_channel"] = self.select_first_open_dropdown_option(frame)
        else:
            channel_options = self.open_modal_select_dropdown_options(frame, ["交易通道"], fallback_index=1)
            if not channel_options:
                raise AssertionError(
                    "线上打款配置-交易通道下拉已展开但无可选项；请使用当前丸友集账号先完成B-SETTLE-PAYCENTER-001的支付中心交易通道/账号配置前置，再继续本用例。"
                )
            result["selected_channel"] = self.select_first_open_dropdown_option(frame)

        assert result.get("selected_channel"), "交易通道下拉有选项但未能选中。"
        assert self.click_frame_action_text(frame, "确 认") or self.click_frame_action_text(frame, "确认"), (
            f"线上打款配置未找到确认按钮；text={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(3000)
        final_text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in final_text for key in ["绑定成功", "配置成功", "保存成功", "线上打款配置"]), (
            f"线上打款配置确认后未看到成功提示或配置入口；text={final_text[:1500]}"
        )
        result["final_text"] = final_text[:1000]
        return result

    def complete_wanyouji_player_settlement_bill(
        self,
        source_order_no: str = "",
        order_title: str = "",
        settlement_method: str = "线下支付",
    ) -> dict[str, str | bool]:
        assert settlement_method in {"线下支付", "线上打款"}, f"未知结算方式：{settlement_method}"
        frame = self.open_workshop_settlement_bill_list()
        before_text = frame.locator("body").inner_text(timeout=5000)
        assert any(key in before_text for key in ["完成结算", "结算中", "待审核", "账单号"]), (
            f"结算账单列表未看到可处理账单；text={before_text[:1500]}"
        )
        self.approve_pending_player_settlement_bill(frame)
        frame = self.open_workshop_settlement_bill_list()
        before_text = self.open_settlement_bill_status_tab(frame, "结算中")

        clicked = self.click_settlement_bill_complete_action(frame, source_order_no, order_title)
        if not clicked:
            assert self.click_settlement_bill_row_action(frame, ["查看", "详情"], required_status="结算中"), (
                f"结算中账单未找到【完成结算】或【查看】入口；text={before_text[:1500]}"
            )
            self.page.wait_for_timeout(1500)
            clicked = self.click_detail_action_text(frame, ["完成结算", "确认结算", "审核通过", "通过", "同意"])
        assert clicked, f"结算账单列表未找到【完成结算】入口；text={before_text[:1500]}"
        self.page.wait_for_timeout(1500)
        modal_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert any(key in modal_text for key in ["确认完结", "请选择结算方式", "审核", "确认", "结算方式"]), (
            f"完成结算/审核后未出现确认弹框；text={modal_text[:1500]}"
        )
        settlement_total_amount = self.extract_settlement_total_amount(modal_text, before_text)

        if "结算方式" in modal_text or "请选择结算方式" in modal_text:
            assert self.click_modal_radio_text(frame, settlement_method), (
                f"确认完结弹框未找到结算方式：{settlement_method}；text={modal_text[:1500]}"
            )
        self.fill_settlement_completion_remark(frame, f"E2E {settlement_method} 完成结算")
        assert self.click_modal_button(frame, ["确认完结", "确认", "确定", "确 定", "确认审核", "审核通过", "通过", "提交"]), (
            f"确认完结/审核弹框未找到确认按钮；text={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(4000)
        final_text = frame.locator("body").inner_text(timeout=5000)

        if settlement_method == "线上打款" and any(key in final_text for key in ["驳回", "失败", "异常", "财务"]):
            return {
                "method": settlement_method,
                "expected_rejected": True,
                "settlement_total_amount": settlement_total_amount,
                "final_text": final_text[:1000],
            }

        assert any(key in final_text for key in ["已结算", "结算完成", "完成结算", "结算中", "账单号"]), (
            f"确认完结后未看到结算完成或账单列表状态；text={final_text[:1500]}"
        )
        return {
            "method": settlement_method,
            "expected_rejected": False,
            "settlement_total_amount": settlement_total_amount,
            "final_text": final_text[:1000],
        }

    def approve_pending_player_settlement_bill(self, frame: Frame) -> None:
        text = self.open_settlement_bill_status_tab(frame, "待审核")
        if not self.settlement_bill_row_exists(text, "待审核"):
            return
        assert self.click_settlement_bill_row_action(frame, ["查看", "详情"], required_status="待审核"), (
            f"待审核账单未找到【查看】入口；text={text[:1500]}"
        )
        self.page.wait_for_timeout(1500)
        detail_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        assert any(key in detail_text for key in ["待审核", "审核", "账单", "预计结算金额", "实际结算金额"]), (
            f"待审核账单详情内容不符合预期；text={detail_text[:1500]}"
        )
        assert self.click_detail_action_text(frame, ["审核通过", "通过", "同意", "确认审核", "审核"]), (
            f"待审核账单详情未找到审核通过入口；text={detail_text[:1500]}"
        )
        self.page.wait_for_timeout(1200)
        confirm_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        if any(key in confirm_text for key in ["审核通过", "通过", "同意"]):
            self.click_modal_radio_text(frame, "审核通过") or self.click_modal_radio_text(frame, "通过") or self.click_modal_radio_text(frame, "同意")
        self.fill_settlement_completion_remark(frame, "E2E 审核通过")
        assert self.click_modal_button(frame, ["确认", "确定", "确 定", "确认审核", "审核通过", "通过", "提交"]), (
            f"待审核账单审核弹框未找到确认入口；text={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(3500)
        frame = self.open_workshop_settlement_bill_list()
        final_text = self.open_settlement_bill_status_tab(frame, "结算中")
        assert "结算中" in final_text or "完成结算" in final_text or "账单号" in final_text, (
            f"待审核账单审核后未进入结算中状态；text={final_text[:1500]}"
        )

    def extract_settlement_total_amount(self, *texts: str) -> str:
        for text in texts:
            if not text:
                continue
            normalized = text.replace(",", "")
            for label in ["实际结算金额", "预计结算金额", "申请金额合计", "结算金额", "金额合计"]:
                match = re.search(
                    rf"{label}[\s\S]{{0,80}}?[¥￥]?\s*([0-9]+(?:\.[0-9]{{1,2}})?)",
                    normalized,
                )
                if match:
                    amount = match.group(1)
                    try:
                        value = float(amount)
                        return str(int(value)) if value.is_integer() else f"{value:.2f}"
                    except ValueError:
                        return amount
        return ""

    def open_settlement_bill_status_tab(self, frame: Frame, status: str) -> str:
        self.click_frame_tab_text(frame, status)
        self.page.wait_for_timeout(1500)
        text = frame.locator("body").inner_text(timeout=5000)
        if "No data" in text or "暂无数据" in text:
            self.click_optional_text(frame, "重 置") or self.click_optional_text(frame, "重置")
            self.page.wait_for_timeout(600)
            self.click_frame_tab_text(frame, status)
            self.page.wait_for_timeout(1500)
            text = frame.locator("body").inner_text(timeout=5000)
        return text

    def settlement_bill_row_exists(self, text: str, status: str = "") -> bool:
        if "No data" in text or "暂无数据" in text:
            return False
        if not re.search(r"\bBD\d{8,}\b", text):
            return False
        return not status or status in text

    def click_settlement_bill_row_action(
        self,
        frame: Frame,
        action_texts: list[str],
        required_status: str = "",
    ) -> bool:
        clicked = frame.evaluate(
            """({ actionTexts, requiredStatus }) => {
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => /BD\\d{8,}/.test(item.text))
                  .filter(item => !requiredStatus || item.text.includes(requiredStatus));
                const row = rows[0];
                if (!row) return false;
                const nodes = Array.from(row.row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    return { el, clickable, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: Boolean(clickable.disabled) };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
                for (const actionText of actionTexts) {
                  const target = nodes.find(item => item.text === actionText || item.text.includes(actionText));
                  if (target) {
                    target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                    target.clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            {"actionTexts": action_texts, "requiredStatus": required_status},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_detail_action_text(self, frame: Frame, action_texts: list[str]) -> bool:
        script = """actionTexts => {
            const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer, .ant-popover, .ant-popconfirm'))
              .filter(el => {
                const r = el.getBoundingClientRect();
                return r.width > 0 && r.height > 0;
              });
            const root = roots[roots.length - 1] || document;
            const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
              .map(el => {
                const clickable = el.closest('button, a, [role="button"]') || el;
                return { el, clickable, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: Boolean(clickable.disabled) };
              })
              .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
            for (const actionText of actionTexts) {
              const target = nodes.find(item => item.text === actionText || item.text.includes(actionText));
              if (target) {
                target.clickable.click();
                return true;
              }
            }
            return false;
        }"""
        clicked = frame.evaluate(script, action_texts)
        if not clicked:
            clicked = self.page.evaluate(script, action_texts)
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def verify_workshop_order_completed_after_player_settlement(self, source_order_no: str, order_title: str) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        assert source_order_no in text or order_title in text, (
            f"完成结算后丸友集订单列表未找到订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        row_text = self.extract_order_row_text(text, source_order_no)
        assert any(key in row_text for key in ["已完成", "完成", "完:"]) or any(
            key in text for key in ["已完成", "订单完结时间", "完: 1"]
        ), (
            f"完成结算后丸友集订单列表状态未同步为已完成；source_order_no={source_order_no}; "
            f"row_text={row_text[:1500]}; text={text[:1500]}"
        )
        return row_text

    def click_settlement_bill_complete_action(self, frame: Frame, source_order_no: str = "", order_title: str = "") -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = rows.find(item => sourceOrderNo && item.text.includes(sourceOrderNo)) ||
                  rows.find(item => orderTitle && item.text.includes(orderTitle)) ||
                  rows.find(item => item.text.includes('完成结算')) ||
                  rows[0];
                if (!row) return false;
                const nodes = Array.from(row.row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    return {
                      el,
                      clickable,
                      text: (el.innerText || el.textContent || '').trim(),
                      clickableText: (clickable.innerText || clickable.textContent || '').trim(),
                      r: el.getBoundingClientRect(),
                      disabled: Boolean(clickable.disabled) || clickable.getAttribute('aria-disabled') === 'true'
                    };
                  })
                  .filter(item => visible(item.el) && !item.disabled);
                const target = nodes.find(item => item.text === '完成结算' || item.clickableText === '完成结算' || item.text.includes('完成结算'));
                if (!target) return false;
                target.clickable.scrollIntoView({ block: 'center', inline: 'center' });
                target.clickable.click();
                return true;
            }""",
            {"sourceOrderNo": source_order_no, "orderTitle": order_title},
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_modal_radio_text(self, frame: Frame, text: str) -> bool:
        clicked = frame.evaluate(
            """text => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots[roots.length - 1] || document;
                const labels = Array.from(root.querySelectorAll('label, .ant-radio-wrapper, span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === text || item.text.includes(text))
                  .sort((a, b) => {
                    if (a.text === text && b.text !== text) return -1;
                    if (b.text === text && a.text !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = labels[0]?.el;
                if (!target) return false;
                const clickable = target.closest('label, .ant-radio-wrapper') || target;
                clickable.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_timeout(600)
        return bool(clicked)

    def fill_settlement_completion_remark(self, frame: Frame, remark: str) -> None:
        script = """remark => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots[roots.length - 1] || document;
                const textarea = Array.from(root.querySelectorAll('textarea'))
                  .find(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0 && !el.disabled;
                  });
                if (!textarea) return false;
                const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
                setter.call(textarea, remark);
                textarea.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: remark }));
                textarea.dispatchEvent(new Event('change', { bubbles: true }));
                textarea.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                return true;
            }"""
        filled = frame.evaluate(
            script,
            remark,
        )
        if not filled:
            self.page.evaluate(script, remark)
        self.page.wait_for_timeout(300)

    def click_frame_action_text(self, frame: Frame, text: str) -> bool:
        return bool(
            frame.evaluate(
                """text => {
                    const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], span, div'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect(), disabled: Boolean(el.disabled) }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .filter(item => item.text === text || item.text.split(/\\s+/).includes(text) || item.text.includes(text))
                      .sort((a, b) => {
                        if (a.text === text && b.text !== text) return -1;
                        if (b.text === text && a.text !== text) return 1;
                        return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                      });
                    const target = nodes[0];
                    if (!target) return false;
                    const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                    clickable.scrollIntoView({ block: 'center', inline: 'center' });
                    clickable.click();
                    return true;
                }""",
                text,
            )
        )

    def open_modal_select_dropdown_options(
        self,
        frame: Frame,
        label_keywords: list[str],
        fallback_index: int | None = None,
    ) -> list[str]:
        opened = frame.evaluate(
            """({ labels, fallbackIndex }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer')).filter(visible);
                const root = roots[roots.length - 1] || document;
                const allSelectors = Array.from(root.querySelectorAll('.ant-select-selector'))
                  .map((el, index) => ({
                    el,
                    index,
                    text: (el.innerText || el.textContent || '').trim(),
                    itemText: (el.closest('.ant-form-item')?.innerText || '').trim(),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const selectors = allSelectors.filter(item =>
                  labels.some(label => item.text.includes(label) || item.itemText.includes(label))
                );
                const target = selectors[0] || (Number.isInteger(fallbackIndex) ? allSelectors[fallbackIndex] : null);
                if (!target) return false;
                target.el.scrollIntoView({ block: 'center', inline: 'center' });
                const r = target.el.getBoundingClientRect();
                target.el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.right - 14, clientY: r.y + r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.right - 14, clientY: r.y + r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.right - 14, clientY: r.y + r.height / 2 }));
                target.el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.right - 14, clientY: r.y + r.height / 2 }));
                return true;
            }""",
            {"labels": label_keywords, "fallbackIndex": fallback_index},
        )
        self.page.wait_for_timeout(800)
        options = self.visible_dropdown_options_in_frame_or_page(frame)
        if not opened or not options:
            self.click_modal_select_arrow_by_mouse(frame, label_keywords, fallback_index)
            self.page.wait_for_timeout(800)
            options = self.visible_dropdown_options_in_frame_or_page(frame)
        return options

    def visible_dropdown_options_in_frame_or_page(self, frame: Frame) -> list[str]:
        options: list[str] = []
        for root in [frame, self.page]:
            try:
                options.extend(
                    root.evaluate(
                        """() => Array.from(document.querySelectorAll(
                          '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled), .ant-select-item-option:not(.ant-select-item-option-disabled)'
                        )).map(el => {
                          const r = el.getBoundingClientRect();
                          return { text: (el.innerText || el.textContent || '').trim(), visible: r.width > 0 && r.height > 0 };
                        }).filter(item => item.visible && item.text).map(item => item.text)"""
                    )
                )
            except Exception:
                continue
        return options

    def click_modal_select_arrow_by_mouse(
        self,
        frame: Frame,
        label_keywords: list[str],
        fallback_index: int | None = None,
    ) -> bool:
        box = frame.evaluate(
            """({ labels, fallbackIndex }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-drawer')).filter(visible);
                const root = roots[roots.length - 1] || document;
                const allSelectors = Array.from(root.querySelectorAll('.ant-select-selector'))
                  .map((el, index) => ({
                    el,
                    index,
                    text: (el.innerText || el.textContent || '').trim(),
                    itemText: (el.closest('.ant-form-item')?.innerText || '').trim(),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const selectors = allSelectors.filter(item =>
                  labels.some(label => item.text.includes(label) || item.itemText.includes(label))
                );
                const target = selectors[0] || (Number.isInteger(fallbackIndex) ? allSelectors[fallbackIndex] : null);
                if (!target) return null;
                target.el.scrollIntoView({ block: 'center', inline: 'center' });
                const r = target.el.getBoundingClientRect();
                return { x: r.right - 14, y: r.y + r.height / 2 };
            }""",
            {"labels": label_keywords, "fallbackIndex": fallback_index},
        )
        if not box:
            return False
        frame_box = frame.frame_element().bounding_box()
        if not frame_box:
            return False
        self.page.mouse.click(frame_box["x"] + box["x"], frame_box["y"] + box["y"])
        return True

    def select_first_open_dropdown_option(self, frame: Frame) -> str:
        selected = ""
        for root in [frame, self.page]:
            try:
                selected = root.evaluate(
                    """() => {
                        const options = Array.from(document.querySelectorAll(
                          '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option:not(.ant-select-item-option-disabled), .ant-select-item-option:not(.ant-select-item-option-disabled)'
                        )).map(el => ({
                          el,
                          text: (el.innerText || el.textContent || '').trim(),
                          r: el.getBoundingClientRect()
                        })).filter(item => item.r.width > 0 && item.r.height > 0 && item.text);
                        const target = options[0];
                        if (!target) return '';
                        target.el.click();
                        return target.text;
                    }"""
                )
            except Exception:
                selected = ""
            if selected:
                break
        self.page.wait_for_timeout(1000)
        return str(selected or "")

    def assert_workshop_finance_settlement_detail_text(
        self,
        text: str,
        *,
        source_order_no: str,
        order_title: str,
        expected_player_amount: str,
        settlement_batch_no: str,
    ) -> None:
        expected_total = self.expected_employee_settlement_total(text, expected_player_amount)
        assert (
            expected_player_amount in text
            or f"¥{expected_player_amount}" in text
            or f"￥{expected_player_amount}" in text
            or str(expected_total) in text
            or f"¥{expected_total}" in text
            or f"￥{expected_total}" in text
        ), (
            f"员工结算汇总详情未看到预期结算金额；source_order_no={source_order_no}; "
            f"expected_single={expected_player_amount}; expected_total={expected_total}; text={text[:1500]}"
        )

    def expected_employee_settlement_total(self, text: str, expected_player_amount: str) -> int:
        try:
            amount = int(float(expected_player_amount))
        except ValueError:
            return 0
        match = re.search(r"订单总数\s*(\d+)", text)
        if not match:
            match = re.search(r"\b(\d+)\s*待审核", text)
        count = int(match.group(1)) if match else 1
        return amount * max(count, 1)

    def click_sidebar_text_scrolled(self, text: str, right_edge: bool = False) -> bool:
        for _ in range(8):
            box = self.page.evaluate(
                """text => {
                    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
                    const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'));
                    const candidates = nodes
                      .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.x >= 0 && item.r.x < 230)
                      .filter(item => item.r.y > 40 && item.r.y < viewportHeight - 20)
                      .filter(item => item.r.width > 20 && item.r.width < 230 && item.r.height > 12 && item.r.height < 80)
                      .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                      .sort((a, b) => {
                        if (a.value === text && b.value !== text) return -1;
                        if (b.value === text && a.value !== text) return 1;
                        return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                      });
                    const target = candidates[0];
                    if (!target) return null;
                    let row = target.el;
                    for (let node = target.el; node && node !== document.body; node = node.parentElement) {
                      const r = node.getBoundingClientRect();
                      const cls = String(node.className || '');
                      if (
                        r.x >= 0 && r.x < 230 &&
                        r.width >= 100 && r.width <= 230 &&
                        r.height >= 28 && r.height <= 58 &&
                        (cls.includes('menu') || cls.includes('item') || node.tagName === 'TARO-VIEW-CORE')
                      ) {
                        row = node;
                        break;
                      }
                    }
                    const r = row.getBoundingClientRect();
                    return { x: r.x, y: r.y, width: r.width, height: r.height };
                }""",
                text,
            )
            if box:
                x = box["x"] + box["width"] - 16 if right_edge else box["x"] + min(box["width"] / 2, 90)
                y = box["y"] + box["height"] / 2
                self.page.mouse.click(x, y)
                self.page.wait_for_load_state("domcontentloaded")
                self.page.wait_for_timeout(1000)
                return True
            self.page.mouse.move(100, 500)
            self.page.mouse.wheel(0, 420)
            self.page.wait_for_timeout(300)

        clicked = self.page.evaluate(
            """({ text, rightEdge }) => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'));
                const candidates = nodes
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 230)
                  .filter(item => item.r.width > 20 && item.r.width < 230 && item.r.height > 12)
                  .filter(item => item.value === text || item.value.split(/\\s+/).includes(text))
                  .sort((a, b) => {
                    if (a.value === text && b.value !== text) return -1;
                    if (b.value === text && a.value !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = candidates[0];
                if (!target) return false;
                target.el.scrollIntoView({ block: 'center', inline: 'nearest' });
                const r = target.el.getBoundingClientRect();
                const clickable = target.el.closest('[role="menuitem"], button, a') || target.el;
                const cr = clickable.getBoundingClientRect();
                const x = rightEdge ? cr.x + cr.width - 16 : cr.x + Math.min(cr.width / 2, 90);
                const y = cr.y + cr.height / 2;
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: x, clientY: y }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
                clickable.click();
                return true;
            }""",
            {"text": text, "rightEdge": right_edge},
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def click_sidebar_leaf_precise(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('li, [role="menuitem"], .ant-menu-item, taro-view-core, div, span'))
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.r.x >= 0 && item.r.x < 230)
                  .filter(item => item.value === text)
                  .sort((a, b) => {
                    const aMenu = String(a.el.className || '').includes('menu') ? 0 : 1;
                    const bMenu = String(b.el.className || '').includes('menu') ? 0 : 1;
                    if (aMenu !== bMenu) return aMenu - bMenu;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                const clickable = target.el.closest('li, [role="menuitem"], .ant-menu-item, button, a') || target.el;
                clickable.scrollIntoView({ block: 'center', inline: 'nearest' });
                const r = clickable.getBoundingClientRect();
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                clickable.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1200)
        return bool(clicked)

    def open_owner_settlement_detail_if_possible(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        keyword: str,
    ) -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, keyword }) => {
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const row = rows.find(item =>
                  item.text.includes(sourceOrderNo) ||
                  item.text.includes(orderTitle) ||
                  (keyword && item.text.includes(keyword))
                )?.row;
                if (!row) return false;
                const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = nodes.find(item => ['查看', '详情', '明细'].some(text => item.text === text || item.text.includes(text))) ||
                  nodes[nodes.length - 1];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                clickable.click();
                return true;
            }""",
            {"sourceOrderNo": source_order_no, "orderTitle": order_title, "keyword": keyword},
        )
        self.page.wait_for_timeout(1500)
        return bool(clicked)

    def open_player_income_detail_if_possible(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        expected_amount: str,
    ) -> bool:
        suffix = order_title[-6:] if len(order_title) >= 6 else order_title
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, suffix, expectedAmount }) => {
                const rows = Array.from(document.querySelectorAll('tbody tr, .ant-table-row'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const exact = rows.find(item => item.text.includes(sourceOrderNo) || item.text.includes(orderTitle));
                const suffixMatch = rows.find(item =>
                  item.text.includes(suffix) &&
                  (item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes('￥' + expectedAmount))
                );
                const amountMatch = rows.find(item =>
                  item.text.includes('已完成') &&
                  (item.text.includes(expectedAmount) || item.text.includes(`¥${expectedAmount}`) || item.text.includes('￥' + expectedAmount))
                );
                const row = (exact || suffixMatch || amountMatch)?.row;
                if (!row) return false;
                const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = nodes.find(item => item.text === '查看') ||
                  nodes.find(item => item.text.includes('查看')) ||
                  nodes[nodes.length - 1];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                clickable.click();
                return true;
            }""",
            {
                "sourceOrderNo": source_order_no,
                "orderTitle": order_title,
                "suffix": suffix,
                "expectedAmount": expected_amount,
            },
        )
        self.page.wait_for_timeout(1500)
        return bool(clicked)

    def click_player_income_order_tab(self, frame: Frame) -> None:
        clicked = frame.evaluate(
            """() => {
                const nodes = Array.from(document.querySelectorAll('.ant-tabs-tab, .ant-tabs-tab-btn, button, a, span, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === '订单' || item.text.split(/\\s+/).includes('订单'))
                  .sort((a, b) => {
                    if (a.text === '订单' && b.text !== '订单') return -1;
                    if (b.text === '订单' && a.text !== '订单') return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = nodes[0];
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        assert clicked, f"打手应用-我的收入未找到订单页签；text={frame.locator('body').inner_text(timeout=5000)[:1200]}"
        self.page.wait_for_timeout(1500)

    def open_player_income(self) -> Frame:
        last_text = ""
        for attempt in range(4):
            self.goto(WANYOUJI_CONSOLE_URL)
            self.wait_for_enterprise_console_shell()
            self.expand_player_app_menu()
            if self.click_sidebar_leaf_strict("我的收入"):
                self.page.wait_for_timeout(4000)
                return self.best_wanyouji_frame(["我的收入", "收入", "来源单号", "订单"])
            last_text = self.page.locator("body").inner_text(timeout=5000)
            self.page.reload(wait_until="domcontentloaded")
            self.page.wait_for_timeout(1500 + attempt * 500)
        raise AssertionError(
            "未找到打手应用-我的收入入口；已刷新后仍只看到当前菜单，"
            f"请检查打手收入菜单权限是否延迟生效；text={last_text[:1200]}"
        )

    def search_workshop_order_list(self, frame: Frame, source_order_no: str, order_title: str) -> str:
        last_text = ""
        for keyword in [source_order_no, order_title]:
            self.reset_search_if_possible(frame)
            self.search_order_if_possible(frame, keyword)
            last_text = frame.locator("body").inner_text(timeout=5000)
            if source_order_no in last_text or order_title in last_text:
                return last_text
        self.reset_search_if_possible(frame)
        last_text = frame.locator("body").inner_text(timeout=5000)
        if source_order_no in last_text or order_title in last_text:
            return last_text
        return last_text

    def verify_workshop_order_list_status(
        self,
        source_order_no: str,
        order_title: str,
        expected_statuses: list[str] | tuple[str, ...],
    ) -> str:
        frame = self.open_workshop_order_list()
        text = self.search_workshop_order_list(frame, source_order_no, order_title)
        row_text = self.extract_order_row_text(text, source_order_no)
        if not row_text and order_title:
            row_text = self.extract_order_row_text(text, order_title)
        target_text = row_text or text
        assert source_order_no in target_text or order_title in target_text or source_order_no in text or order_title in text, (
            f"丸友集订单列表未找到目标订单；source_order_no={source_order_no}; "
            f"order_title={order_title}; text={text[:1500]}"
        )
        row_status = self.extract_workshop_order_row_status(target_text)
        assert any(status in target_text or status == row_status for status in expected_statuses), (
            f"丸友集订单列表状态不符合预期；source_order_no={source_order_no}; order_title={order_title}; "
            f"expected_statuses={list(expected_statuses)}; row_status={row_status}; row_text={target_text[:1500]}"
        )
        return target_text

    @staticmethod
    def filtered_order_list_has_single_result(text: str) -> bool:
        compact = re.sub(r"\s+", "", text or "")
        return "共1条记录" in compact or "共1条" in compact

    def reset_search_if_possible(self, frame: Frame) -> None:
        self.click_optional_text(frame, "重 置") or self.click_optional_text(frame, "重置")
        self.page.wait_for_timeout(800)

    def visible_page_and_frame_text(self, frame: Frame) -> str:
        parts: list[str] = []
        try:
            parts.append("[page] " + self.page.locator("body").inner_text(timeout=3000))
        except Exception as exc:
            parts.append(f"[page read failed] {exc}")
        try:
            parts.append("[frame] " + frame.locator("body").inner_text(timeout=3000))
        except Exception as exc:
            parts.append(f"[frame read failed] {exc}")
        return "\n".join(parts)

    def order_still_waiting_to_accept(self, frame: Frame, source_order_no: str, order_title: str) -> bool:
        try:
            text = frame.locator("body").inner_text(timeout=5000)
        except Exception:
            return False
        if source_order_no not in text and order_title not in text:
            return False
        return "待接单" in text and "立即接单" in text

    def refresh_order_hall_captcha_attempt(self, scope_text: str, attempt: int) -> None:
        # A wrong captcha leaves the order in 待接单. Re-open the row on the next loop with a fresh captcha.
        self.page.wait_for_timeout(1000 + attempt * 500)

    def reopen_order_hall_frame(self, scope_text: str, source_order_no: str) -> Frame:
        frames = self.open_order_hall()
        for candidate in frames:
            if scope_text == "私单" and not self.frame_has_order_hall_tabs(candidate):
                continue
            self.click_order_hall_tab(candidate, scope_text)
            self.search_order_if_possible(candidate, source_order_no)
            try:
                text = candidate.locator("body").inner_text(timeout=3000)
            except Exception:
                continue
            if source_order_no in text or self.frame_has_order_hall_tabs(candidate):
                return candidate
        return frames[0]

    def scope_candidates(self, expected_scope: str) -> list[str]:
        if expected_scope == "私单":
            return ["私单"]
        if expected_scope in {"公池单", "公池单+私单"}:
            return ["公池单", "私单", "全部"]
        return ["私单", "公池单", "全部"]

    def best_wanyouji_frame(self, keywords: list[str]) -> Frame:
        for frame in self.wanyouji_business_frames():
            if self.frame_contains_any(frame, keywords):
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到丸友集业务 iframe；keywords={keywords}; frames={urls}")

    def wanyouji_business_frames(self) -> list[Frame]:
        frames = [
            frame
            for frame in self.page.frames
            if frame.url != "about:blank"
            and (
                "test-wshop" in frame.url
                or "test-orbit" in frame.url
                or "test-booster" in frame.url
                or "test-ledger" in frame.url
            )
        ]
        main_frame = self.page.main_frame
        if main_frame.url != "about:blank" and (
            "test-wshop" in main_frame.url
            or "test-orbit" in main_frame.url
            or "test-booster" in main_frame.url
            or "test-ledger" in main_frame.url
        ):
            frames.insert(0, main_frame)
        return frames

    def retry_if_app_load_timeout(self) -> None:
        for _ in range(3):
            try:
                text = self.page.locator("body").inner_text(timeout=3000)
            except Exception:
                return
            if "应用加载超时" not in text and "重试" not in text:
                return
            clicked = self.page.evaluate(
                """() => {
                    const nodes = Array.from(document.querySelectorAll('button, div, span, a'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text === '重试' || item.text.includes('重试'))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                    const target = nodes[0]?.el;
                    if (!target) return false;
                    target.click();
                    return true;
                }"""
            )
            if not clicked:
                self.page.reload()
            self.page.wait_for_timeout(4000)

    def frame_contains_any(self, frame: Frame, keywords: list[str]) -> bool:
        try:
            text = frame.locator("body").inner_text(timeout=1000)
        except Exception:
            return False
        return any(keyword in text for keyword in keywords)

    def click_row_action(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_texts: list[str],
    ) -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, actionTexts }) => {
                const badAction = text => ['拒绝', '取消', '关闭', '删除', '撤销'].some(key => text.includes(key));
                const norm = text => String(text || '').replace(/\s+/g, '');
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const actionNodes = Array.from(document.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    const text = (el.innerText || el.textContent || '').trim();
                    const clickableText = (clickable.innerText || clickable.textContent || text).trim();
                    const r = el.getBoundingClientRect();
                    return { el, clickable, text, clickableText, r };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && !badAction(item.text) && !badAction(item.clickableText));
                const anchors = Array.from(document.querySelectorAll('td, div, span, p'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => (sourceOrderNo && item.text.includes(sourceOrderNo)) || (orderTitle && item.text.includes(orderTitle)))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                for (const anchor of anchors.slice(0, 3)) {
                  const candidates = actionNodes
                    .filter(item => item.r.y >= anchor.r.y - 24 && item.r.y <= anchor.r.y + Math.max(anchor.r.height, 160))
                    .filter(item => actionTexts.some(actionText =>
                      norm(item.text) === norm(actionText) ||
                      norm(item.clickableText) === norm(actionText) ||
                      norm(item.text).includes(norm(actionText)) ||
                      norm(item.clickableText).includes(norm(actionText))
                    ))
                    .sort((a, b) => {
                      const aExact = actionTexts.some(text => norm(a.text) === norm(text) || norm(a.clickableText) === norm(text)) ? 0 : 1;
                      const bExact = actionTexts.some(text => norm(b.text) === norm(text) || norm(b.clickableText) === norm(text)) ? 0 : 1;
                      if (aExact !== bExact) return aExact - bExact;
                      return Math.abs(a.r.y - anchor.r.y) - Math.abs(b.r.y - anchor.r.y) ||
                        (a.r.width * a.r.height) - (b.r.width * b.r.height);
                    });
                  const target = candidates[0];
                  if (target) {
                    target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.click();
                    return true;
                  }
                }
                const allRows = Array.from(document.querySelectorAll('tr, .ant-table-row, [class*="table"] [class*="row"], [class*="order"], [class*="card"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => (sourceOrderNo && item.text.includes(sourceOrderNo)) || (orderTitle && item.text.includes(orderTitle)));
                const rows = allRows
                  .filter(item => Array.from(item.row.querySelectorAll('button, a, [role="button"], span')).some(visible))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const row = rows[0]?.row;
                if (!row) return false;
                const nodes = Array.from(row.querySelectorAll('button, a, [role="button"], span'))
                  .map(el => {
                    const clickable = el.closest('button, a, [role="button"]') || el;
                    return { el, clickable, text: (el.innerText || el.textContent || '').trim(), clickableText: (clickable.innerText || clickable.textContent || '').trim(), r: el.getBoundingClientRect() };
                  })
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text && !badAction(item.text) && !badAction(item.clickableText))
                  .sort((a, b) => a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height));
                for (const actionText of actionTexts) {
                  const target = nodes.find(item => norm(item.text) === norm(actionText) || norm(item.clickableText) === norm(actionText)) ||
                    nodes.find(item =>
                      (norm(item.text).includes(norm(actionText)) || norm(item.clickableText).includes(norm(actionText))) &&
                      !badAction(item.text) &&
                      !badAction(item.clickableText)
                    );
                  if (target) {
                    target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            {
                "sourceOrderNo": source_order_no,
                "orderTitle": order_title,
                "actionTexts": action_texts,
            },
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)

    def click_row_action_allow_revoke(
        self,
        frame: Frame,
        source_order_no: str,
        order_title: str,
        action_texts: list[str],
    ) -> bool:
        clicked = frame.evaluate(
            """({ sourceOrderNo, orderTitle, actionTexts }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const norm = value => String(value || '').replace(/\\s+/g, '');
                const targets = actionTexts.map(norm);
                const rows = Array.from(document.querySelectorAll('tbody tr.ant-table-row, tbody tr, tr, [class*="row"], [class*="order"], [class*="card"]'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(sourceOrderNo) || item.text.includes(orderTitle))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const row = rows[0]?.row;
                const roots = row ? [row, document] : [document];
                for (const root of roots) {
                  const nodes = Array.from(root.querySelectorAll('button, a, [role="button"], span'))
                    .map(el => {
                      const clickable = el.closest('button, a, [role="button"]') || el;
                      const text = (el.innerText || el.textContent || '').trim();
                      const clickableText = (clickable.innerText || clickable.textContent || text).trim();
                      const r = el.getBoundingClientRect();
                      return { el, clickable, text, clickableText, r };
                    })
                    .filter(item => item.r.width > 0 && item.r.height > 0)
                    .filter(item => item.text || item.clickableText)
                    .sort((a, b) => {
                      const aExact = targets.includes(norm(a.text)) || targets.includes(norm(a.clickableText)) ? 0 : 1;
                      const bExact = targets.includes(norm(b.text)) || targets.includes(norm(b.clickableText)) ? 0 : 1;
                      return aExact - bExact || a.text.length - b.text.length || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                    });
                  for (const actionText of targets) {
                    const target = nodes.find(item => norm(item.text) === actionText || norm(item.clickableText) === actionText) ||
                      nodes.find(item => norm(item.text).includes(actionText) || norm(item.clickableText).includes(actionText));
                    if (!target) continue;
                    target.clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                    target.clickable.click();
                    return true;
                  }
                }
                return false;
            }""",
            {
                "sourceOrderNo": source_order_no,
                "orderTitle": order_title,
                "actionTexts": action_texts,
            },
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def confirm_open_dialog(self, frame: Frame) -> None:
        clicked = frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal, .ant-popover, .ant-popconfirm'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const buttons = Array.from(root.querySelectorAll('button'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const positiveTexts = ['立即接单', '确认接单', '接单', '确认发单', '继续发单', '确认', '确定', '是'];
                const candidates = buttons.filter(item =>
                  positiveTexts.some(text => item.text === text || item.text.includes(text))
                );
                const target = candidates.find(item => String(item.el.className || '').includes('primary')) ||
                  candidates[candidates.length - 1];
                if (!target) return false;
                const clickable = target.el.closest('button, a, [role="button"]') || target.el;
                clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: target.r.x + target.r.width / 2, clientY: target.r.y + target.r.height / 2 }));
                clickable.click();
                return true;
            }"""
        )
        if not clicked:
            self.page.keyboard.press("Enter")
        self.page.wait_for_timeout(1200)

    def confirm_grab_order_dialog(self, frame: Frame, numeric_only: bool = False) -> bool:
        if not self.grab_order_dialog_visible(frame):
            self.confirm_open_dialog(frame)
            return True
        last_code = ""
        deadline = datetime.now().timestamp() + int(os.getenv("E2E_GRAB_CAPTCHA_TIMEOUT_SECONDS", "300"))
        while datetime.now().timestamp() < deadline:
            last_code = self.fill_grab_order_graphic_captcha(frame, numeric_only=numeric_only)
            if not last_code:
                self.refresh_grab_order_graphic_captcha(frame)
                continue
            clicked = self.click_grab_order_confirm_button(frame)
            assert clicked, f"抢单确认弹框未找到确认抢单按钮；text={frame.locator('body').inner_text(timeout=5000)[:1200]}"
            self.page.wait_for_timeout(2500)
            text = frame.locator("body").inner_text(timeout=5000)
            if not self.grab_order_dialog_visible(frame):
                return True
            if not any(key in text for key in ["验证码错误", "验证码失败", "验证码不能为空", "图形验证码", "请输入验证码", "请输入图形验证码"]):
                return True
            self.refresh_grab_order_graphic_captcha(frame)
        raise AssertionError(f"抢单确认图形验证码在超时时间内未能识别并通过；最后识别：{last_code}")

    def click_grab_order_confirm_button(self, frame: Frame) -> bool:
        for text in ["确认抢单", "确认接单", "立即抢单"]:
            button = frame.locator(".ant-modal button:visible").filter(has_text=text).last
            if button.count():
                button.click(force=True)
                self.page.wait_for_timeout(800)
                return True
        return False

    def grab_order_dialog_visible(self, frame: Frame) -> bool:
        if frame.locator(".ant-modal:visible").filter(has_text="确认抢单").count():
            return True
        try:
            text = frame.locator("body").inner_text(timeout=3000)
        except Exception:
            return False
        return (
            "抢单确认" in text
            or ("图形验证码" in text and "确认抢单" in text)
            or ("安全验证" in text and "确认抢单" in text)
            or ("验证码不能为空" in text and "确认抢单" in text)
        )

    def fill_grab_order_graphic_captcha(self, frame: Frame, numeric_only: bool = False) -> str:
        src = self.grab_order_graphic_captcha_src(frame)
        image_bytes = decode_data_image(src)
        raw_code = (
            self.solve_numeric_grab_captcha(image_bytes)
            if numeric_only
            else self.image_captcha_solver.solve(image_bytes)
        )
        code = "".join(ch for ch in raw_code if ch.isdigit()) if numeric_only else raw_code
        print(f"[E2E] 抢单图形验证码 OCR raw={raw_code!r} digits={code!r} len={len(code)}")
        if numeric_only and len(code) != 4:
            return ""
        assert code, f"抢单确认验证码识别结果没有数字；raw={raw_code}"
        input = frame.locator(".ant-modal input[placeholder*='验证码']:visible").last
        if input.count() == 0:
            input = frame.locator(".ant-modal input:visible").last
        assert input.count(), (
            f"抢单确认弹框未找到验证码输入框；text={frame.locator('body').inner_text(timeout=5000)[:1200]}"
        )
        input.click(force=True)
        input.fill("")
        input.type(code, delay=80)
        input.evaluate(
            """(el) => {
                el.dispatchEvent(new Event('input', { bubbles: true }));
                el.dispatchEvent(new Event('change', { bubbles: true }));
            }"""
        )
        input.press("Tab")
        self.page.wait_for_timeout(300)
        actual = input.input_value(timeout=3000)
        assert actual == code, f"抢单确认图形验证码未正确写入；expected={code}; actual={actual}"
        return code

    def solve_numeric_grab_captcha(self, image_bytes: bytes) -> str:
        candidates = [image_bytes]
        try:
            from PIL import Image, ImageEnhance, ImageFilter
            image = Image.open(io.BytesIO(image_bytes)).convert("L")
            variants = [
                image,
                ImageEnhance.Contrast(image).enhance(2.2),
                image.filter(ImageFilter.SHARPEN),
                image.point(lambda p: 255 if p > 145 else 0),
                ImageEnhance.Contrast(image).enhance(3.0).point(lambda p: 255 if p > 135 else 0),
            ]
            for variant in variants:
                output = io.BytesIO()
                variant.save(output, format="PNG")
                candidates.append(output.getvalue())
        except Exception:
            pass

        results: list[str] = []
        for candidate in candidates:
            try:
                raw = self.image_captcha_solver.solve(candidate)
            except Exception as exc:
                raw = f"<ocr failed: {exc}>"
            results.append(raw)
            digits = "".join(ch for ch in raw if ch.isdigit())
            if len(digits) == 4:
                return digits
        return results[0] if results else ""

    def grab_order_graphic_captcha_src(self, frame: Frame) -> str:
        src = ""
        for _ in range(10):
            src = frame.evaluate(
                """() => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop() || document;
                    const inputs = Array.from(root.querySelectorAll('input'))
                      .map(el => ({ el, placeholder: el.getAttribute('placeholder') || '', r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                    const input = inputs.find(item => item.placeholder.includes('验证码') || item.placeholder.includes('图形验证码')) ||
                      inputs[inputs.length - 1];
                    const images = Array.from(root.querySelectorAll('img'))
                      .map(img => ({ img, src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                      .filter(item => item.src.startsWith('data:image') && item.r.width > 30 && item.r.height > 20);
                    if (input) {
                      images.sort((a, b) => Math.abs(a.r.y - input.r.y) - Math.abs(b.r.y - input.r.y));
                    }
                    return images[0]?.src || '';
                }"""
            )
            if src:
                return str(src)
            self.page.wait_for_timeout(500)
        raise AssertionError(f"抢单确认弹框未找到图形验证码图片；text={frame.locator('body').inner_text(timeout=5000)[:1200]}")

    def refresh_grab_order_graphic_captcha(self, frame: Frame) -> None:
        before_src = ""
        try:
            before_src = self.grab_order_graphic_captcha_src(frame)
        except Exception:
            before_src = ""
        frame.evaluate(
            """() => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const refreshText = Array.from(root.querySelectorAll('button, a, span, div'))
                  .map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').trim(),
                    className: String(el.className || ''),
                    r: el.getBoundingClientRect()
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item =>
                    item.text === '刷新' ||
                    item.text === '换一张' ||
                    /refresh/i.test(item.className)
                  )
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                if (refreshText) {
                  refreshText.el.click();
                  return;
                }
                const images = Array.from(root.querySelectorAll('img'))
                  .map(img => ({ img, src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                  .filter(item => item.src.startsWith('data:image') && item.r.width > 30 && item.r.height > 20);
                images[0]?.img.click();
            }"""
        )
        for _ in range(8):
            self.page.wait_for_timeout(300)
            try:
                after_src = self.grab_order_graphic_captcha_src(frame)
            except Exception:
                continue
            if after_src and after_src != before_src:
                return
        self.page.wait_for_timeout(800)

    def click_visible_action_text(self, frame: Frame, action_texts: list[str]) -> bool:
        return bool(
            frame.evaluate(
                """actionTexts => {
                    const normalized = value => String(value || '').replace(/\\s+/g, '');
                    const targets = actionTexts.map(normalized).filter(Boolean);
                    const nodes = Array.from(document.querySelectorAll(
                      '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option, button, a, [role="button"], li, span, div'
                    ))
                      .map(el => {
                        const clickable = el.closest('button, a, [role="button"], li, .ant-select-item-option') || el;
                        return {
                          el,
                          clickable,
                          text: (el.innerText || el.textContent || '').trim(),
                          clickableText: (clickable.innerText || clickable.textContent || '').trim(),
                          r: el.getBoundingClientRect(),
                          disabled: Boolean(clickable.disabled) || clickable.getAttribute('aria-disabled') === 'true'
                        };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .sort((a, b) => {
                        const aOption = String(a.clickable.className || '').includes('ant-select-item-option') ? 0 : 1;
                        const bOption = String(b.clickable.className || '').includes('ant-select-item-option') ? 0 : 1;
                        return aOption - bOption || (a.r.width * a.r.height) - (b.r.width * b.r.height);
                      });
                    for (const targetText of targets) {
                      const target = nodes.find(item =>
                        normalized(item.text) === targetText ||
                        normalized(item.clickableText) === targetText
                      ) || nodes.find(item =>
                        normalized(item.text).includes(targetText) ||
                        normalized(item.clickableText).includes(targetText)
                      );
                      if (!target) continue;
                      target.clickable.click();
                      return true;
                    }
                    return false;
                }""",
                action_texts,
            )
        )

    def select_assignment_method(self, frame: Frame, assignment_method: str) -> None:
        if assignment_method == "抢单大厅":
            return
        clicked_selector = frame.evaluate(
            """method => {
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(el => {
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0;
                  });
                const root = roots.pop() || document;
                const row = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => /抢单大厅|指派打手|智能派单|选择打手|固定发单价|百分比发单价/.test(item.text))[0]?.row || root;
                const selectors = Array.from(row.querySelectorAll('.ant-select-selector, [class*="select-selector"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || el.value || el.getAttribute('value') || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => /抢单大厅|指派打手|智能派单/.test(item.text))
                  .sort((a, b) => a.r.x - b.r.x);
                const target = selectors[0]?.el;
                if (!target) return false;
                const r = target.getBoundingClientRect();
                const options = { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
                target.dispatchEvent(new MouseEvent('mouseover', options));
                target.dispatchEvent(new MouseEvent('mousedown', options));
                target.dispatchEvent(new MouseEvent('mouseup', options));
                target.click();
                return true;
            }""",
            assignment_method,
        )
        assert clicked_selector, (
            f"发单表格未找到分配方式下拉；method={assignment_method}；modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(600)
        option_clicked = self.click_dropdown_option_anywhere(frame, assignment_method)
        if not option_clicked:
            self.page.keyboard.press("ArrowDown")
            self.page.wait_for_timeout(200)
            self.page.keyboard.press("Enter")
            self.page.wait_for_timeout(600)
            modal_after_key = self.visible_modal_text(frame)
            option_clicked = assignment_method in modal_after_key
        assert option_clicked, (
            f"分配方式下拉未找到选项：{assignment_method}；modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(800)

    def click_dropdown_option_anywhere(self, frame: Frame, option_text: str) -> bool:
        for root in [self.page, frame]:
            try:
                options = root.locator(
                    ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option, "
                    "[role='listbox'] [role='option'], li, .ant-select-item-option"
                ).filter(has_text=option_text)
                if options.count():
                    options.last.click(force=True)
                    self.page.wait_for_timeout(500)
                    return True
            except Exception:
                continue
        return self.click_visible_action_text(frame, [option_text])

    def select_dispatch_player(self, frame: Frame, player_name: str = "", player_phone: str = "") -> None:
        keywords = [value for value in [player_name, player_phone] if value]
        clicked = self.click_visible_action_text(frame, ["选择打手"])
        assert clicked, (
            "指派打手发单弹框未找到【选择打手】入口；"
            f"player={player_name or player_phone}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        modal_text = self.wait_for_player_select_modal(frame)
        assert modal_text, (
            "点击【选择打手】后未打开打手选择弹框；"
            f"player={player_name or player_phone}; text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        if keywords:
            for keyword in keywords:
                if self.fill_player_select_keyword(frame, keyword):
                    self.click_modal_button(frame, ["查 询", "查询"])
                    self.page.wait_for_timeout(1500)
                    break
        selected = self.click_player_select_row(frame, keywords)
        assert selected, (
            "打手选择弹框未找到可选择的启用打手；"
            f"player={player_name or player_phone}; modal={self.visible_modal_text(frame)[:1500]}"
        )
        confirmed = self.click_player_select_confirm(frame)
        assert confirmed, (
            "打手选择弹框未找到【确认选择】按钮；"
            f"player={player_name or player_phone}; modal={self.visible_modal_text(frame)[:1500]}"
        )
        self.page.wait_for_timeout(1000)
        dispatch_text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        expected = player_name or player_phone
        assert not expected or expected in dispatch_text, (
            f"确认选择打手后发单弹框未回填目标打手；player={expected}; modal={dispatch_text[:1500]}"
        )

    def click_player_select_confirm(self, frame: Frame) -> bool:
        script = """() => {
            const visible = el => {
              const r = el.getBoundingClientRect();
              return r.width > 0 && r.height > 0;
            };
            const modals = Array.from(document.querySelectorAll('.ant-modal'))
              .filter(visible)
              .filter(modal => (modal.innerText || modal.textContent || '').includes('选择打手'));
            const modal = modals.pop();
            if (!modal) return false;
            const buttons = Array.from(modal.querySelectorAll('button'))
              .map(button => ({ button, text: (button.innerText || button.textContent || '').trim(), r: button.getBoundingClientRect(), disabled: button.disabled }))
              .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled);
            const target = buttons.find(item => item.text === '确认选择') ||
              buttons.find(item => item.text.includes('确认选择'));
            if (!target) return false;
            target.button.click();
            return true;
        }"""
        clicked = bool(frame.evaluate(script))
        if not clicked:
            clicked = bool(self.page.evaluate(script))
        self.page.wait_for_timeout(1000)
        return clicked

    def wait_for_player_select_modal(self, frame: Frame, timeout_ms: int = 10000) -> str:
        last_text = ""
        for _ in range(max(1, timeout_ms // 500)):
            last_text = self.visible_modal_text(frame)
            if "选择打手" in last_text and any(key in last_text for key in ["成员名称", "订单类型", "确认选择"]):
                return last_text
            self.page.wait_for_timeout(500)
        return ""

    def fill_player_select_keyword(self, frame: Frame, keyword: str) -> bool:
        return bool(
            frame.evaluate(
                """keyword => {
                    const roots = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(el => {
                        const r = el.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const root = roots.pop() || document;
                    const inputs = Array.from(root.querySelectorAll('input'))
                      .map(el => ({
                        el,
                        placeholder: el.getAttribute('placeholder') || '',
                        r: el.getBoundingClientRect()
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0);
                    const target = inputs.find(item => /成员名称|手机号|名称|搜索/.test(item.placeholder)) || inputs[0];
                    if (!target) return false;
                    target.el.focus();
                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    setter.call(target.el, keyword);
                    target.el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: keyword }));
                    target.el.dispatchEvent(new Event('change', { bubbles: true }));
                    target.el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                keyword,
            )
        )

    def click_player_select_row(self, frame: Frame, keywords: list[str]) -> bool:
        return bool(
            frame.evaluate(
                """keywords => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const roots = Array.from(document.querySelectorAll('.ant-modal')).filter(visible);
                    const root = roots.pop() || document;
                    const rows = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                      .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text && !/暂无数据|无数据|禁用|停用|离职/.test(item.text));
                    const matchedRows = keywords.length
                      ? rows.filter(item => keywords.some(keyword => keyword && item.text.includes(keyword)))
                      : rows;
                    const row = (matchedRows[0] || rows[0])?.row;
                    if (!row) return false;
                    const radioInput = row.querySelector('input.ant-radio-input[type="radio"], input[type="radio"]');
                    if (!radioInput) return false;
                    const radioBox = radioInput.closest('.ant-radio, label, td') || radioInput;
                    const r = radioBox.getBoundingClientRect();
                    const options = { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
                    radioBox.dispatchEvent(new MouseEvent('mouseover', options));
                    radioBox.dispatchEvent(new MouseEvent('mousedown', options));
                    radioBox.dispatchEvent(new MouseEvent('mouseup', options));
                    radioBox.click();
                    radioInput.dispatchEvent(new Event('change', { bubbles: true }));
                    return Boolean(radioInput.checked) ||
                      String(radioInput.closest('.ant-radio')?.className || '').includes('checked') ||
                      String(row.className || '').includes('selected');
                }""",
                keywords,
            )
        )

    def fill_dispatch_modal_defaults(self, frame: Frame, source_order_no: str, order_title: str) -> None:
        price_mode = self.resolve_workshop_dispatch_price_mode()
        price_label = "百分比发单价" if price_mode == "percent" else "固定发单价"
        self.select_workshop_dispatch_price_mode(frame, price_label)
        defaults = {
            "订单标题": order_title,
            "代练时限": "14",
            "时间要求": "14",
            "安全保证金": "0",
            "效率保证金": "0",
            "联系电话": "13800138000",
            "订单备注": f"E2E外部订单发单 {source_order_no}",
        }
        if price_mode == "percent":
            defaults["收单价百分比"] = self.resolve_workshop_dispatch_price_value()
            defaults["价格百分比"] = self.resolve_workshop_dispatch_price_value()
            defaults["百分比"] = self.resolve_workshop_dispatch_price_value()
        else:
            defaults["发单价"] = self.resolve_workshop_dispatch_price_value()
        frame.evaluate(
            """defaults => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const setValue = (target, value) => {
                    if (!target) return false;
                    target.scrollIntoView({ block: 'center', inline: 'center' });
                    target.focus();
                    const proto = target.tagName === 'TEXTAREA'
                      ? window.HTMLTextAreaElement.prototype
                      : window.HTMLInputElement.prototype;
                    const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                    setter.call(target, value);
                    target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    target.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                };
                const roots = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible);
                const root = roots.pop() || document;
                const inputs = Array.from(root.querySelectorAll('input, textarea'));
                const rows = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.row.querySelector('input, textarea, .ant-select-selector'));
                const row = rows[0]?.row;
                const inputByHeader = headerText => {
                    if (!row) return null;
                    const cells = Array.from(row.children);
                    const table = row.closest('table');
                    const headers = table ? Array.from(table.querySelectorAll('thead th')) : [];
                    const index = headers.findIndex(th => (th.innerText || th.textContent || '').includes(headerText));
                    const cell = index >= 0 ? cells[index] : null;
                    return cell ? Array.from(cell.querySelectorAll('input, textarea')).find(item => visible(item) && !item.disabled) : null;
                };
                for (const [key, value] of Object.entries(defaults)) {
                    const targetByHeader = inputByHeader(key);
                    if (setValue(targetByHeader, value)) continue;
                    const targetByPlaceholder = inputs.find(input => {
                        const placeholder = input.getAttribute('placeholder') || '';
                        return placeholder.includes(key);
                    });
                    setValue(targetByPlaceholder, value);
                }
            }""",
            defaults,
        )
        self.page.wait_for_timeout(500)
        self.assert_workshop_dispatch_price_ready(frame)

    def assert_workshop_dispatch_price_ready(self, frame: Frame) -> None:
        price_mode = self.resolve_workshop_dispatch_price_mode()
        price_label = "百分比发单价" if price_mode == "percent" else "固定发单价"
        expected_value = self.resolve_workshop_dispatch_price_value()
        state = frame.evaluate(
            """({ priceMode, priceLabel, expectedValue }) => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const root = Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop() || document;
                const rows = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.row.querySelector('input, textarea, .ant-select-selector'));
                const row = rows[0]?.row;
                const selectedTexts = row
                  ? Array.from(row.querySelectorAll('.ant-select-selection-item, .ant-select-selector'))
                      .map(el => `${el.getAttribute('title') || ''} ${el.innerText || el.textContent || ''}`.trim())
                      .filter(Boolean)
                  : [];
                const inputByHeader = headerText => {
                    if (!row) return null;
                    const cells = Array.from(row.children);
                    const table = row.closest('table');
                    const headers = table ? Array.from(table.querySelectorAll('thead th')) : [];
                    const index = headers.findIndex(th => (th.innerText || th.textContent || '').includes(headerText));
                    const cell = index >= 0 ? cells[index] : null;
                    const input = cell ? Array.from(cell.querySelectorAll('input, textarea')).find(item => visible(item) && !item.disabled) : null;
                    if (!input) return null;
                    return {
                        placeholder: input.getAttribute('placeholder') || '',
                        value: input.value || input.getAttribute('value') || '',
                    };
                };
                const priceInput = priceMode === 'percent'
                  ? inputByHeader('收单价百分比')
                  : inputByHeader('发单价');
                const numericEqual = (actual, expected) => {
                  const left = Number(String(actual || '').replace(/[^\d.]/g, ''));
                  const right = Number(String(expected || '').replace(/[^\d.]/g, ''));
                  return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) < 0.001;
                };
                return {
                  modeOk: selectedTexts.some(text => text.includes(priceLabel)) || (row?.innerText || '').includes(priceLabel),
                  priceValueOk: Boolean(priceInput && numericEqual(priceInput.value, expectedValue)),
                  priceInput,
                  selectedTexts,
                  rowText: (row?.innerText || row?.textContent || '').slice(0, 1200),
                  modalText: (root.innerText || root.textContent || '').slice(0, 1500),
                };
            }""",
            {"priceMode": price_mode, "priceLabel": price_label, "expectedValue": expected_value},
        )
        assert state.get("modeOk"), f"发单弹框价格模式未确认到【{price_label}】，禁止确认发单；state={state}"
        assert state.get("priceValueOk"), (
            f"发单弹框价格值未确认到【{expected_value}】，禁止确认发单；state={state}"
        )

    def assert_no_dispatch_validation_error(self, frame: Frame, source_order_no: str) -> None:
        self.page.wait_for_timeout(800)
        text = self.visible_modal_text(frame) or frame.locator("body").inner_text(timeout=5000)
        compact = re.sub(r"\s+", "", text)
        blockers = [
            "请填写发单价",
            "请填写收单价百分比",
            "请填写价格百分比",
            "第1个子单请填写",
            "请选择打手",
            "请填写",
        ]
        assert not any(blocker in compact for blocker in blockers), (
            f"确认发单后仍有必填提示，不能继续后续流程；source_order_no={source_order_no}; modal={text[:1500]}"
        )

    @staticmethod
    def resolve_workshop_dispatch_price_mode() -> str:
        raw = os.getenv("E2E_TASK_DISPATCH_PRICE_MODE", os.getenv("E2E_S_FLOW_DISPATCH_PRICE_MODE", "percent")).strip().lower()
        if raw in {"percent", "percentage", "百分比", "百分比发单价"}:
            return "percent"
        if raw in {"fixed", "amount", "固定", "固定发单价"}:
            return "fixed"
        raise AssertionError(
            "E2E_TASK_DISPATCH_PRICE_MODE/E2E_S_FLOW_DISPATCH_PRICE_MODE "
            f"仅支持 fixed/固定发单价 或 percent/百分比发单价；当前值：{raw}"
        )

    def resolve_workshop_dispatch_price_value(self) -> str:
        if self.resolve_workshop_dispatch_price_mode() == "percent":
            value = os.getenv(
                "E2E_TASK_DISPATCH_PRICE_PERCENT",
                os.getenv("E2E_S_FLOW_DISPATCH_PRICE_PERCENT", default_dispatch_price_percent()),
            ).strip()
            assert value.isdigit() and DISPATCH_PRICE_PERCENT_MIN <= int(value) <= DISPATCH_PRICE_PERCENT_MAX, (
                "E2E_TASK_DISPATCH_PRICE_PERCENT "
                f"必须为 {DISPATCH_PRICE_PERCENT_MIN}-{DISPATCH_PRICE_PERCENT_MAX} 的整数；当前值：{value}"
            )
            return value
        return os.getenv("E2E_TASK_DISPATCH_PRICE", os.getenv("E2E_S_FLOW_DISPATCH_PRICE", "70")).strip() or "70"

    def select_workshop_dispatch_price_mode(self, frame: Frame, price_label: str) -> None:
        result = frame.evaluate(
            """priceLabel => {
                const visible = el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                };
                const root = Array.from(document.querySelectorAll('.ant-modal'))
                  .filter(visible)
                  .reverse()
                  .find(el => /确认发单|请添加子单|固定发单价|百分比发单价|发单价|价格百分比/.test(el.innerText || el.textContent || ''));
                if (!root) return { ok: false, reason: 'modal not found' };
                const rows = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                  .map(row => ({ row, text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => /抢单大厅|指派打手|智能派单|固定发单价|百分比发单价/.test(item.text));
                const row = rows[0]?.row || root;
                const text = row.innerText || row.textContent || '';
                const selectors = Array.from(row.querySelectorAll('.ant-select-selector, [class*="select-selector"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || el.value || el.getAttribute('value') || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const selector = selectors.find(item => /固定发单价|百分比发单价/.test(item.text));
                if (!selector) return { ok: priceLabel === '固定发单价', reason: 'price selector not found', text, selectors: selectors.map(item => item.text) };
                if (selector.text.includes(priceLabel)) return { ok: true, already: true, text, selectorText: selector.text };
                selector.el.scrollIntoView({ block: 'center', inline: 'center' });
                const r = selector.el.getBoundingClientRect();
                const options = { bubbles: true, cancelable: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 };
                selector.el.dispatchEvent(new MouseEvent('mouseover', options));
                selector.el.dispatchEvent(new MouseEvent('mousedown', options));
                selector.el.dispatchEvent(new MouseEvent('mouseup', options));
                selector.el.click();
                return { ok: true, clicked: true, selectorText: selector.text, text };
            }""",
            price_label,
        )
        if result.get("clicked"):
            self.page.wait_for_timeout(600)
            if self.click_dropdown_option_anywhere(frame, price_label):
                self.page.wait_for_timeout(1000)
        if price_label == "百分比发单价":
            current = frame.evaluate(
                """() => {
                    const visible = el => {
                      const r = el.getBoundingClientRect();
                      return r.width > 0 && r.height > 0;
                    };
                    const root = Array.from(document.querySelectorAll('.ant-modal')).filter(visible).pop();
                    if (!root) return '';
                    const rows = Array.from(root.querySelectorAll('tbody tr, .ant-table-row, tr'))
                      .map(row => ({ text: (row.innerText || row.textContent || '').trim(), r: row.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => /抢单大厅|指派打手|智能派单|固定发单价|百分比发单价/.test(item.text));
                    return rows[0]?.text || '';
                }"""
            )
            assert price_label in current or result.get("already"), (
                f"丸友集发单弹框未能切换到【{price_label}】；result={result}; text={current[:1500]}"
            )

    def search_order_if_possible(self, frame: Frame, keyword: str) -> None:
        for label in ["来源单号", "业务单号", "订单标题", "申请/批次号", "批次号", "打手账号"]:
            if self.fill_input_near_label(frame, label, keyword):
                self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")
                self.page.wait_for_timeout(2000)
                return
        inputs = frame.locator("input")
        for index in range(inputs.count()):
            locator = inputs.nth(index)
            try:
                placeholder = locator.get_attribute("placeholder") or ""
                if any(key in placeholder for key in ["来源单号", "订单标题", "业务单号", "批次号", "申请", "打手", "关键字"]):
                    locator.fill(keyword)
                    self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")
                    self.page.wait_for_timeout(2000)
                    return
            except Exception:
                continue

    def search_order_by_best_identity(
        self,
        frame: Frame,
        *,
        main_order_no: str = "",
        source_order_no: str = "",
        order_title: str = "",
    ) -> None:
        if main_order_no and main_order_no.startswith("BO"):
            if self.search_order_by_labels(frame, main_order_no, ["上游单号", "主单号", "订单号", "订单编号"]):
                return
        if source_order_no:
            if self.search_order_by_labels(frame, source_order_no, ["来源单号", "业务单号"]):
                return
            self.search_order_if_possible(frame, source_order_no)
            return
        if order_title:
            if self.search_order_by_labels(frame, order_title, ["订单标题"]):
                return
            self.search_order_if_possible(frame, order_title)

    def search_order_by_labels(self, frame: Frame, keyword: str, labels: list[str]) -> bool:
        for label in labels:
            if self.fill_input_near_label(frame, label, keyword):
                self.click_optional_text(frame, "查 询") or self.click_optional_text(frame, "查询")
                self.page.wait_for_timeout(2000)
                return True
        return False

    def fill_input_near_label(self, frame: Frame, label_text: str, value: str) -> bool:
        return bool(
            frame.evaluate(
                """({ labelText, value }) => {
                    const nodes = Array.from(document.querySelectorAll('label, div, span'))
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.text === labelText && item.r.width > 0 && item.r.height > 0)
                      .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                    const label = nodes[0];
                    if (!label) return false;
                    const inputs = Array.from(document.querySelectorAll('input'))
                      .map(el => ({ el, r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => Math.abs(item.r.y - label.r.y) < 24 && item.r.x > label.r.x)
                      .sort((a, b) => a.r.x - b.r.x);
                    const input = inputs[0]?.el;
                    if (!input) return false;
                    input.focus();
                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    setter.call(input, value);
                    input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    input.dispatchEvent(new Event('change', { bubbles: true }));
                    input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"labelText": label_text, "value": value},
            )
        )

    def click_optional_text(self, frame: Frame, text: str) -> bool:
        clicked = frame.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('button, span, div, a'));
                const target = nodes
                  .map(el => ({ el, value: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .find(item => item.value === text || item.value.includes(text));
                if (!target) return false;
                target.el.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_timeout(800)
        return bool(clicked)
