Commit 44dd226a authored by wushanjing's avatar wushanjing

修正智能接单多账号和担保单流程

parent 072be617
......@@ -25,6 +25,16 @@ Use the repository's automation core instead of rediscovering test state manuall
```
5. Patch Page Objects in `e2e/pages/` before patching tests when the problem is selector or navigation reuse.
## Multi-Account Browser Rule
When creating or updating E2E cases that involve more than one logged-in tenant account, use the shared Chrome dual-account fixture instead of switching accounts in one page.
- Add `dual_account_pages` to the pytest function arguments.
- Call `wanzhanggui_page, wanyouji_page = dual_account_pages.pair(wanzhanggui_phone, wanyouji_phone)` before constructing Page Objects.
- Construct each Page Object with its own page, for example `WanzhangguiOrderPage(wanzhanggui_page, ...)` and `WanyoujiFulfillmentPage(wanyouji_page, ...)`.
- Do not manually launch browsers or create contexts in tests. The fixture routes accounts to one Chrome browser with isolated BrowserContexts/windows and asserts that the contexts are distinct.
- Keep `E2E_DUAL_BROWSER` enabled. It is enabled by default; only single-account tests may opt out.
## Profiles
- `seed`: data/account creation tests.
......
......@@ -4,18 +4,19 @@ import os
from dataclasses import dataclass
import pytest
from dotenv import load_dotenv
from playwright.sync_api import Page
from e2e.utils.bug_reporter import BugReporter
from e2e.utils.dual_browser import dual_browser_enabled, dual_browser_manager
from e2e.utils.dual_browser import assert_distinct_account_contexts, dual_browser_enabled, dual_browser_manager
from e2e.utils.env_config import load_e2e_env
load_dotenv()
load_e2e_env()
@dataclass(frozen=True)
class E2ESettings:
e2e_env: str
tenant_base_url: str
tenant_home_url: str
admin_url: str
......@@ -48,9 +49,30 @@ class E2ESettings:
viewport_height: int
@dataclass
class DualAccountPages:
seed_page: Page
viewport: dict[str, int]
def page_for(self, phone: str) -> Page:
assert dual_browser_enabled(), "多账号用例必须开启 E2E_DUAL_BROWSER,默认值为开启。"
return dual_browser_manager().page_for_account(
phone,
viewport=self.viewport,
seed_page=self.seed_page,
)
def pair(self, left_phone: str, right_phone: str) -> tuple[Page, Page]:
left_page = self.page_for(left_phone)
right_page = self.page_for(right_phone)
assert_distinct_account_contexts(left_page, left_phone, right_page, right_phone)
return left_page, right_page
@pytest.fixture(scope="session")
def settings() -> E2ESettings:
return E2ESettings(
e2e_env=os.getenv("E2E_ENV", "test"),
tenant_base_url=os.getenv("TENANT_BASE_URL", "https://test-lianlianyun.lianlianwz.com"),
tenant_home_url=os.getenv("TENANT_HOME_URL", "https://test-lianlianyun.lianlianwz.com/home"),
admin_url=os.getenv("ADMIN_URL", "https://test-saas-adminui.lianlianwz.com/pages/home/index"),
......@@ -73,8 +95,8 @@ def settings() -> E2ESettings:
feishu_poll_interval_seconds=int(os.getenv("FEISHU_POLL_INTERVAL_SECONDS", "3")),
certified_tenant_phone=os.getenv("E2E_CERTIFIED_TENANT_PHONE", "19361091624"),
certified_tenant_password=os.getenv("E2E_CERTIFIED_TENANT_PASSWORD", "test123456"),
certified_tenant_enterprise_name=os.getenv("E2E_CERTIFIED_TENANT_ENTERPRISE_NAME", "E2E测试企业0603174618802"),
certified_tenant_merchant_id=os.getenv("E2E_CERTIFIED_TENANT_MERCHANT_ID", "99"),
certified_tenant_enterprise_name=os.getenv("E2E_CERTIFIED_TENANT_ENTERPRISE_NAME", "E2E已认证企业"),
certified_tenant_merchant_id=os.getenv("E2E_CERTIFIED_TENANT_MERCHANT_ID", ""),
wanyouji_product_id=os.getenv("E2E_WANYOUJI_PRODUCT_ID", "12"),
wanzhanggui_product_id=os.getenv("E2E_WANZHANGGUI_PRODUCT_ID", "15"),
wanzhanggui_tenant_phone=os.getenv("E2E_WANZHANGGUI_TENANT_PHONE", "19147509308"),
......@@ -112,6 +134,17 @@ def browser_context_args(browser_context_args, settings: E2ESettings):
}
@pytest.fixture
def dual_account_pages(page: Page, settings: E2ESettings) -> DualAccountPages:
return DualAccountPages(
seed_page=page,
viewport={
"width": settings.viewport_width,
"height": settings.viewport_height,
},
)
@pytest.fixture
def bug_reporter(page: Page, request) -> BugReporter:
marker_to_case = {
......
......@@ -11,7 +11,7 @@ from pathlib import Path
from playwright.sync_api import Frame, Page, expect
from e2e.pages.tenant_auth_page import ENTERPRISE_CONSOLE_URL, TenantAuthPage
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 (
......@@ -20,11 +20,14 @@ from e2e.utils.data_factory import (
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"
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"
......@@ -46,7 +49,7 @@ class ManualOrderData:
main_order_no: str = ""
receive_price: str = "80"
dispatch_price: str = "70"
dispatch_price_mode: str = "percent"
dispatch_price_mode: str = ""
dispatch_price_percent: str = ""
sub_order_title: str = ""
security_deposit: str = "0"
......@@ -89,20 +92,29 @@ class WanzhangguiOrderPage(TenantAuthPage):
super().__init__(page, tenant_home_url, bug_reporter)
def open_order_management(self) -> Frame:
self.goto(WANZHANGGUI_CONSOLE_URL)
self.goto_wanzhanggui_console()
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_sidebar_text("订单管理")
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=["订单管理"],
)
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; }", WANZHANGGUI_ORDER_LIST_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()
......@@ -110,10 +122,21 @@ class WanzhangguiOrderPage(TenantAuthPage):
if attempt == 2:
raise
self.dismiss_transient_order_error()
self.click_sidebar_text("订单管理")
self.click_sidebar_text("订单管理列表" if self.is_production() else "订单管理")
self.page.wait_for_timeout(3500)
return self.wait_for_order_list_ready()
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):
......@@ -124,13 +147,13 @@ class WanzhangguiOrderPage(TenantAuthPage):
self.dismiss_transient_order_error(frame)
self.page.wait_for_timeout(1200)
continue
if "手工录单" in last_text and "订单编号" in last_text:
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; }", WANZHANGGUI_ORDER_LIST_URL)
frame.evaluate("url => { window.location.href = url; }", self.order_list_url())
except Exception:
try:
self.page.reload(wait_until="domcontentloaded")
......@@ -140,7 +163,11 @@ class WanzhangguiOrderPage(TenantAuthPage):
continue
if attempt == 4:
try:
self.click_sidebar_text("订单管理")
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)
......@@ -154,7 +181,11 @@ class WanzhangguiOrderPage(TenantAuthPage):
self.click_sidebar_text("商家管理", right_edge=True)
self.page.wait_for_timeout(800)
try:
self.click_sidebar_text("主单申诉单")
self.click_sidebar_path(
"/orderManage/orderAppeal",
app_hint="Saas-福单",
fallback_texts=["主单申诉单"],
)
self.page.wait_for_timeout(2500)
except Exception:
pass
......@@ -262,7 +293,7 @@ class WanzhangguiOrderPage(TenantAuthPage):
def order_frame(self) -> Frame:
for frame in self.page.frames:
if "test-order-pc" in frame.url:
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}")
......@@ -352,6 +383,7 @@ class WanzhangguiOrderPage(TenantAuthPage):
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
......@@ -542,7 +574,7 @@ class WanzhangguiOrderPage(TenantAuthPage):
return trade_record["row_text"]
def open_guarantee_trade_detail_page(self) -> Page:
self.goto(ENTERPRISE_CONSOLE_URL)
self.goto_enterprise_console()
self.wait_for_enterprise_console_shell()
try:
self.click_sidebar_text("企业概览")
......@@ -566,6 +598,7 @@ class WanzhangguiOrderPage(TenantAuthPage):
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
......@@ -4750,7 +4783,7 @@ class WanzhangguiOrderPage(TenantAuthPage):
@staticmethod
def resolve_sub_order_price_mode(order: ManualOrderData) -> str:
raw = os.getenv("E2E_S_FLOW_DISPATCH_PRICE_MODE", order.dispatch_price_mode or "percent").strip().lower()
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", "固定", "固定发单价"}:
......@@ -4771,6 +4804,8 @@ class WanzhangguiOrderPage(TenantAuthPage):
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:
......@@ -5019,7 +5054,13 @@ class WanzhangguiOrderPage(TenantAuthPage):
return
selector = selectors.first
current_text = selector.inner_text(timeout=5000).strip()
if current_text and "请选择" not in current_text and "璇烽€夋嫨" not in current_text and text not in current_text:
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)
......@@ -9306,7 +9347,17 @@ class WanyoujiFulfillmentPage(TenantAuthPage):
self.goto(WANYOUJI_CONSOLE_URL)
self.wait_for_enterprise_console_shell()
self.expand_player_app_menu()
if self.click_sidebar_leaf_strict("我的订单"):
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:
......@@ -9623,6 +9674,12 @@ class WanyoujiFulfillmentPage(TenantAuthPage):
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)
......
......@@ -293,6 +293,7 @@ def test_自动接单用例_普通单发单和分支校验(
page: Page,
settings,
bug_reporter,
dual_account_pages,
case: AutoAcceptanceCase,
) -> None:
store = account_store()
......@@ -307,8 +308,9 @@ def test_自动接单用例_普通单发单和分支校验(
f"丸友集账号 {case.wanyouji_phone} 未开通丸友集,当前状态:{wanyouji_account.wanyouji_status}"
)
order_page = WanzhangguiOrderPage(page, settings.tenant_home_url, bug_reporter)
fulfillment_page = WanyoujiFulfillmentPage(page, settings.tenant_home_url, bug_reporter)
wanzhanggui_page, wanyouji_page = dual_account_pages.pair(case.wanzhanggui_phone, case.wanyouji_phone)
order_page = WanzhangguiOrderPage(wanzhanggui_page, settings.tenant_home_url, bug_reporter)
fulfillment_page = WanyoujiFulfillmentPage(wanyouji_page, settings.tenant_home_url, bug_reporter)
try:
if case.workshop_name:
order_page.login_main_account(case.wanzhanggui_phone, wanzhanggui_account.password)
......@@ -464,6 +466,7 @@ def test_自动接单用例_担保单发单和担保交易校验(
page: Page,
settings,
bug_reporter,
dual_account_pages,
case: GuaranteeAutoAcceptanceCase,
) -> None:
store = account_store()
......@@ -503,10 +506,11 @@ def test_自动接单用例_担保单发单和担保交易校验(
role_name=f"AAG角色{timestamp[-4:]}",
)
order_page = WanzhangguiOrderPage(page, settings.tenant_home_url, bug_reporter)
wanzhanggui_page, wanyouji_page = dual_account_pages.pair(case.wanzhanggui_phone, case.wanyouji_phone)
order_page = WanzhangguiOrderPage(wanzhanggui_page, settings.tenant_home_url, bug_reporter)
try:
if case.merchant_name:
fulfillment_page = WanyoujiFulfillmentPage(page, settings.tenant_home_url, bug_reporter)
fulfillment_page = WanyoujiFulfillmentPage(wanyouji_page, settings.tenant_home_url, bug_reporter)
fulfillment_page.login_main_account(case.wanyouji_phone, wanyouji_account.password)
fulfillment_page.ensure_auto_accept_assignment(case.merchant_name, enabled=True)
bug_reporter.record_if_errors(
......@@ -562,7 +566,7 @@ def test_自动接单用例_担保单发单和担保交易校验(
location=f"{case.case_id} 丸掌柜担保交易金额校验 {fulfillment_order_no}",
)
else:
fulfillment_page = WanyoujiFulfillmentPage(page, settings.tenant_home_url, bug_reporter)
fulfillment_page = WanyoujiFulfillmentPage(wanyouji_page, settings.tenant_home_url, bug_reporter)
fulfillment_page.login_main_account(case.wanyouji_phone, wanyouji_account.password)
bug_reporter.record_if_errors(
account=case.wanyouji_phone,
......
......@@ -30,12 +30,12 @@ def role_for_phone(phone: str) -> str:
wanzhanggui_phone = (
os.getenv("E2E_DUAL_BROWSER_WANZHANGGUI_PHONE", "").strip()
or os.getenv("E2E_ORDER_WANZHANGGUI_PHONE", "").strip()
or os.getenv("E2E_WANZHANGGUI_TENANT_PHONE", "19147509308").strip()
or os.getenv("E2E_WANZHANGGUI_TENANT_PHONE", "17335564065").strip()
)
if phone == wanzhanggui_phone:
return "wanzhanggui"
account = account_store().find(phone=phone)
account = account_store().find_main(phone) or account_store().find(phone=phone)
if account and account.parent_phone:
role = account.member_role or account.contact_name or "sub"
if "打手" in role or role.lower() == "player":
......@@ -44,7 +44,7 @@ def role_for_phone(phone: str) -> str:
return f"wanyouji_customer_service_{phone}"
return f"wanyouji_member_{phone}"
if account and account.wanyouji_status == "opened":
return "wanyouji"
return f"wanyouji_{phone}"
if account and account.parent_phone:
role = account.member_role or account.contact_name or "sub"
if "鎵撴墜" in role or "打手" in role or role.lower() == "player":
......@@ -180,6 +180,25 @@ class DualBrowserManager:
setattr(slot.page, "_e2e_current_main_account", phone)
self.set_active_page(slot.page)
def register_page_for_account(self, phone: str, page: Page) -> None:
role = role_for_phone(phone)
self.register_page_for_role(role, page, current_phone=phone)
def register_page_for_role(self, role: str, page: Page, current_phone: str = "") -> None:
slot = self._slots.get(role)
if not slot:
return
setattr(page, "_e2e_dual_browser_role", role)
setattr(page, "_e2e_dual_browser_channel", slot.channel)
if current_phone:
setattr(page, "_e2e_current_main_account", current_phone)
slot.page = page
slot.context = page.context
slot.browser = page.context.browser or slot.browser
if current_phone:
slot.current_phone = current_phone
self.set_active_page(page)
def _launch_slot(
self,
role: str,
......
from __future__ import annotations
import os
from pathlib import Path
from urllib.parse import urlparse
from dotenv import dotenv_values, load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_E2E_ENV = "test"
VALID_E2E_ENVS = {"test", "prod"}
PROD_HOST_SUFFIXES = ("shunfju.com",)
TEST_HOST_SUFFIXES = ("lianlianwz.com",)
def e2e_env() -> str:
value = os.getenv("E2E_ENV", DEFAULT_E2E_ENV).strip().lower()
return value if value in VALID_E2E_ENVS else DEFAULT_E2E_ENV
def load_e2e_env(project_root: Path | None = None) -> str:
root = project_root or PROJECT_ROOT
shell_keys = set(os.environ)
base_env = root / ".env"
if base_env.exists():
load_dotenv(base_env, override=False)
selected_env = e2e_env()
env_file = root / f".env.{selected_env}"
if env_file.exists():
for key, value in dotenv_values(env_file).items():
if value is not None and key not in shell_keys:
os.environ[key] = value
os.environ.setdefault("E2E_ENV", selected_env)
return selected_env
def is_production_env() -> bool:
return e2e_env() == "prod"
def allow_env_url_mismatch() -> bool:
return os.getenv("E2E_ALLOW_ENV_URL_MISMATCH", "").strip().lower() in {"1", "true", "yes", "on"}
def _host_matches(hostname: str, suffixes: tuple[str, ...]) -> bool:
host = hostname.lower().rstrip(".")
return any(host == suffix or host.endswith(f".{suffix}") for suffix in suffixes)
def _is_test_host(hostname: str) -> bool:
host = hostname.lower().rstrip(".")
return _host_matches(host, TEST_HOST_SUFFIXES) and (host.startswith("test-") or ".test-" in host)
def assert_url_allowed_for_env(url: str) -> None:
if allow_env_url_mismatch():
return
parsed = urlparse(url)
if parsed.scheme in {"", "about", "data", "blob", "file"}:
return
hostname = parsed.hostname or ""
selected_env = e2e_env()
if selected_env == "test" and _host_matches(hostname, PROD_HOST_SUFFIXES):
raise AssertionError(
f"E2E_ENV=test forbids production URL: {url}. "
"Use test URLs/accounts, or set E2E_ALLOW_ENV_URL_MISMATCH=1 only for an explicit manual check."
)
if selected_env == "prod" and _is_test_host(hostname):
raise AssertionError(
f"E2E_ENV=prod forbids test URL: {url}. "
"Use production URLs/accounts, or set E2E_ALLOW_ENV_URL_MISMATCH=1 only for an explicit manual check."
)
......@@ -14,6 +14,7 @@ markers =
e2e_push_003: Wanzhanggui creates dispatch strategy
e2e_order_001: Wanzhanggui manual order dispatch and Wanyouji order hall verification
e2e_order_002: Wanzhanggui revoke dispatch and Wanyouji order hall invisibility verification
e2e_auto_acceptance: Wanzhanggui/Wanyouji smart auto-acceptance flows
data_config_001: Wanzhanggui data dictionary setup for work-order aftersale options
data_config_002: Wanzhanggui data dictionary setup for work-order urgency options
data_config_003: Wanzhanggui data dictionary setup for work-order follow-up options
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment