diff --git a/client/src/pdd_u2_purchase_reconcile_adapter.py b/client/src/pdd_u2_purchase_reconcile_adapter.py index a8c8db4..dd21e11 100644 --- a/client/src/pdd_u2_purchase_reconcile_adapter.py +++ b/client/src/pdd_u2_purchase_reconcile_adapter.py @@ -9,6 +9,7 @@ from __future__ import annotations import re import time import xml.etree.ElementTree as ET +from collections import Counter from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any, Callable, Optional @@ -96,6 +97,26 @@ def _package_hint_from_tree(root: ET.Element) -> str: return PDD_PACKAGE_NAME if pdd_node_count >= 3 else "" +def _foreground_package_from_tree(root: ET.Element) -> str: + """控件树只有一个明确业务包时返回它;系统界面节点不参与判断。""" + + ignored_packages = {"", "com.android.systemui"} + counts = Counter( + node.get("package", "") + for node in root.iter("node") + if node.get("package", "") not in ignored_packages + ) + if not counts: + return "" + highest = max(counts.values()) + packages = [ + package + for package, count in counts.items() + if count == highest and count >= 3 + ] + return packages[0] if len(packages) == 1 else "" + + def _label_count(root: ET.Element, targets: tuple[str, ...]) -> int: return sum( 1 @@ -329,7 +350,10 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter): device_service: Optional[PddDeviceService] = None, cancelled: Callable[[], bool] = lambda: False, settle_seconds: float = 0.4, + transition_timeout_seconds: float = 30.0, max_pages: int = 5, + monotonic: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, ) -> None: self._device_address = str(device_address or "").strip() self._device_service = ( @@ -339,7 +363,12 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter): ) self._cancelled = cancelled self._settle_seconds = max(0.0, float(settle_seconds)) + self._transition_timeout_seconds = max( + 0.0, float(transition_timeout_seconds) + ) self._max_pages = max(1, int(max_pages)) + self._monotonic = monotonic + self._sleep = sleep self._session = None self._device = None @@ -431,23 +460,39 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter): return self._device def _open_unpaid_orders(self, device: Any) -> None: - root = _parse_xml(device.dump_hierarchy(compressed=False)) - if not _package_hint_from_tree(root): - current = device.app_current() - if str(current.get("package") or "") != PDD_PACKAGE_NAME: - device.app_start(PDD_PACKAGE_NAME) - self._settle() - + deadline = self._monotonic() + self._transition_timeout_seconds opened_order_area = False - for _ in range(6): + checked_foreground = False + started_pdd = False + pressed_back = False + clicked_navigation: set[str] = set() + last_reason = "等待 PDD 待付款页稳定" + while self._monotonic() <= deadline: self._check_cancelled() root = _parse_xml(device.dump_hierarchy(compressed=False)) + if self._monotonic() > deadline: + last_reason = "读取手机控件树超时" + break if not _package_hint_from_tree(root): - current = device.app_current() - if str(current.get("package") or "") != PDD_PACKAGE_NAME: - raise PddPurchaseReconcileError( - "RECONCILE_WRONG_APP", "核单时 PDD 不在前台" - ) + if not checked_foreground: + current_package = _foreground_package_from_tree(root) + if not current_package: + current = device.app_current() + current_package = str(current.get("package") or "") + if self._monotonic() > deadline: + last_reason = "查询手机前台应用超时" + break + checked_foreground = True + if current_package != PDD_PACKAGE_NAME: + device.app_start(PDD_PACKAGE_NAME) + started_pdd = True + last_reason = "已返回 PDD,等待待付款页加载" + elif started_pdd: + last_reason = "PDD 正在从微信中间页恢复" + else: + last_reason = "PDD 控件树正在加载" + self._settle() + continue self._raise_for_special_page(root) combined = _subtree_text(root) if _is_single_order_detail(root) or ( @@ -460,27 +505,44 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter): ): return if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS): - device.press("back") + if not pressed_back: + device.press("back") + pressed_back = True + last_reason = "已只读返回,等待待付款页稳定" self._settle() continue if "我的订单" in combined: - if self._click_unique_label(device, root, "待付款"): + if ( + "待付款" not in clicked_navigation + and self._click_unique_label(device, root, "待付款") + ): + clicked_navigation.add("待付款") opened_order_area = True + last_reason = "已打开待付款,等待订单列表加载" self._settle() continue - if self._click_unique_label(device, root, "我的订单"): + if ( + "我的订单" not in clicked_navigation + and self._click_unique_label(device, root, "我的订单") + ): + clicked_navigation.add("我的订单") opened_order_area = True + last_reason = "已打开我的订单,等待待付款入口加载" self._settle() continue - if self._click_unique_label(device, root, "个人中心"): + if ( + "个人中心" not in clicked_navigation + and self._click_unique_label(device, root, "个人中心") + ): + clicked_navigation.add("个人中心") + last_reason = "已打开个人中心,等待我的订单入口加载" self._settle() continue - raise PddPurchaseReconcileError( - "RECONCILE_ORDER_ENTRY_NOT_FOUND", - "没有找到唯一的“个人中心/我的订单/待付款”只读入口", - ) + last_reason = "PDD 页面仍在过渡,尚未出现唯一只读订单入口" + self._settle() raise PddPurchaseReconcileError( - "RECONCILE_ORDER_PAGE_TIMEOUT", "打开待付款订单列表超时" + "RECONCILE_ORDER_PAGE_TIMEOUT", + f"等待 PDD 待付款页稳定超时:{last_reason}", ) @staticmethod @@ -534,7 +596,7 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter): def _settle(self) -> None: if self._settle_seconds: - time.sleep(self._settle_seconds) + self._sleep(self._settle_seconds) def create_u2_purchase_reconcile_adapter( diff --git a/client/src/purchase_reconcile_service.py b/client/src/purchase_reconcile_service.py index 0982b41..9b6cdfb 100644 --- a/client/src/purchase_reconcile_service.py +++ b/client/src/purchase_reconcile_service.py @@ -133,7 +133,10 @@ class PurchaseReconcileService: return self._save_manual_review( task, run, "ambiguous", diagnostics ) - match_status = "not_found" if not candidates else "unknown" + read_failed = bool(diagnostics.get("error_code")) + match_status = ( + "unknown" if read_failed or candidates else "not_found" + ) return self._save_manual_review(task, run, match_status, diagnostics) def _read_candidates( @@ -153,9 +156,13 @@ class PurchaseReconcileService: ): raise TypeError("采购核对候选数据无效") except Exception as exc: + error_code = str( + getattr(exc, "code", "RECONCILE_READ_FAILED") + or "RECONCILE_READ_FAILED" + ) scan = PurchaseReconcileScan( diagnostics={ - "error_code": "RECONCILE_READ_FAILED", + "error_code": error_code, "error_message": str(exc)[:300], } ) @@ -181,13 +188,16 @@ class PurchaseReconcileService: dict(diagnostics), ) messages = { - "not_found": "未找到符合时间范围的订单", + "not_found": "未找到符合条件的订单", "ambiguous": "找到多个完全匹配的未付款订单", "unknown": "订单字段不完整、不一致或读取失败", } + message = messages[match_status] + if match_status == "unknown" and diagnostics.get("error_message"): + message = f"订单读取失败:{str(diagnostics['error_message'])[:200]}" return PurchaseReconcileOutcome( "manual_review", - f"任务 {task.remote_task_id} {messages[match_status]};需人工处理,绝不重新下单", + f"任务 {task.remote_task_id} {message};需人工处理,绝不重新下单", task.remote_task_id, ) diff --git a/client/test/test_pdd_u2_purchase_reconcile_adapter.py b/client/test/test_pdd_u2_purchase_reconcile_adapter.py index 8709854..02ca766 100644 --- a/client/test/test_pdd_u2_purchase_reconcile_adapter.py +++ b/client/test/test_pdd_u2_purchase_reconcile_adapter.py @@ -8,6 +8,7 @@ from src.pdd_purchase_reconcile_adapter import ( PurchaseReconcileQuery, ) from src.pdd_u2_purchase_reconcile_adapter import ( + PddPurchaseReconcileError, U2PddPurchaseReconcileAdapter, _is_single_order_detail, _merge_order_evidence, @@ -96,6 +97,51 @@ class DetailDevice: self.page_index = min(self.page_index + 1, len(self.pages) - 1) +class FakeClock: + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +def xml_package_page(package: str, text: str = "") -> str: + return ( + '' + f'' + f'' + f'' + "" + ) + + +class TransitionDevice(DetailDevice): + def __init__(self, pages, current_package="com.tencent.mm"): + super().__init__(pages) + self.current_package = current_package + self.dump_calls = 0 + self.back_calls = 0 + + def dump_hierarchy(self, compressed=False): + index = min(self.dump_calls, len(self.pages) - 1) + self.dump_calls += 1 + return self.pages[index] + + def app_current(self): + self.app_current_calls += 1 + return {"package": self.current_package} + + def press(self, key): + if key == "back": + self.back_calls += 1 + + def swipe(self, *args): + self.swipes += 1 + + class PddU2PurchaseReconcileAdapterTest(unittest.TestCase): def test_parser_extracts_only_required_unpaid_order_fields(self): candidates = parse_order_candidates( @@ -202,6 +248,132 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase): self.assertFalse(_is_single_order_detail(root)) + def test_waits_for_pdd_detail_after_external_transition(self): + complete_detail = xml_detail( + "待付款 订单编号:ORDER-20260810 " + "商品编号:737116531267 黑色 L 共2件 合计 ¥84.00 " + "下单时间:2026-08-10 16:03:00" + ) + device = TransitionDevice( + [ + xml_package_page("com.tencent.mm", "微信登录"), + xml_package_page("com.xunmeng.pinduoduo", "页面加载中"), + complete_detail, + ] + ) + clock = FakeClock() + adapter = U2PddPurchaseReconcileAdapter( + "serial", + device_service=FakeDeviceService(device), + settle_seconds=1, + transition_timeout_seconds=30, + max_pages=2, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + + scan = adapter.read_order_candidates(query()) + adapter.close() + + self.assertEqual(len(scan.candidates), 1) + self.assertEqual(device.app_start_calls, 1) + self.assertEqual(device.app_current_calls, 0) + self.assertLess(clock.now, 30) + + def test_payment_back_is_pressed_at_most_once_while_page_changes(self): + payment_page = xml_package_page( + "com.xunmeng.pinduoduo", "立即支付" + ) + device = TransitionDevice( + [payment_page, payment_page, xml_detail("待付款")], + current_package="com.xunmeng.pinduoduo", + ) + clock = FakeClock() + adapter = U2PddPurchaseReconcileAdapter( + "serial", + device_service=FakeDeviceService(device), + settle_seconds=1, + transition_timeout_seconds=30, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + + adapter._open_unpaid_orders(device) + + self.assertEqual(device.back_calls, 1) + + def test_transition_timeout_reports_last_waiting_reason(self): + device = TransitionDevice( + [xml_package_page("com.xunmeng.pinduoduo", "页面加载中")], + current_package="com.xunmeng.pinduoduo", + ) + clock = FakeClock() + adapter = U2PddPurchaseReconcileAdapter( + "serial", + device_service=FakeDeviceService(device), + settle_seconds=1, + transition_timeout_seconds=2, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + + with self.assertRaises(PddPurchaseReconcileError) as captured: + adapter._open_unpaid_orders(device) + + self.assertEqual(captured.exception.code, "RECONCILE_ORDER_PAGE_TIMEOUT") + self.assertIn("页面仍在过渡", str(captured.exception)) + + def test_transition_wait_can_be_cancelled(self): + device = TransitionDevice( + [xml_package_page("com.xunmeng.pinduoduo", "页面加载中")], + current_package="com.xunmeng.pinduoduo", + ) + clock = FakeClock() + checks = iter((False, False, True)) + adapter = U2PddPurchaseReconcileAdapter( + "serial", + device_service=FakeDeviceService(device), + cancelled=lambda: next(checks, True), + settle_seconds=1, + transition_timeout_seconds=30, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + + with self.assertRaises(PddPurchaseReconcileError) as captured: + adapter.read_order_candidates(query()) + + self.assertEqual(captured.exception.code, "RECONCILE_CANCELLED") + + def test_security_pages_still_stop_transition_wait(self): + cases = ( + ("请完成验证", "RECONCILE_CAPTCHA"), + ("手机号登录", "RECONCILE_LOGIN_REQUIRED"), + ("操作频繁", "RECONCILE_RISK_CONTROL"), + ) + for marker, error_code in cases: + with self.subTest(marker=marker): + device = TransitionDevice( + [xml_package_page("com.xunmeng.pinduoduo", marker)], + current_package="com.xunmeng.pinduoduo", + ) + clock = FakeClock() + adapter = U2PddPurchaseReconcileAdapter( + "serial", + device_service=FakeDeviceService(device), + settle_seconds=1, + transition_timeout_seconds=30, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + + with self.assertRaises(PddPurchaseReconcileError) as captured: + adapter._open_unpaid_orders(device) + + self.assertEqual(captured.exception.code, error_code) + self.assertEqual(device.app_start_calls, 0) + self.assertEqual(device.back_calls, 0) + if __name__ == "__main__": unittest.main() diff --git a/client/test/test_purchase_recovery.py b/client/test/test_purchase_recovery.py index 7b8bb41..66e6dd5 100644 --- a/client/test/test_purchase_recovery.py +++ b/client/test/test_purchase_recovery.py @@ -327,11 +327,14 @@ class PurchaseRecoveryTest(unittest.TestCase): ).execute_one() self.assertEqual(outcome.kind, "manual_review") + self.assertIn("核对设备已断开", outcome.message) + self.assertNotIn("时间范围", outcome.message) self.assertEqual(purchase_calls, []) detail = self.repository.get_task(task_id) run = self.repository.latest_task_run(task_id) assert detail is not None and run is not None self.assertEqual(detail.current_step, "reconcile_manual_review") + self.assertEqual(run.error_code, "ORDER_MATCH_UNCERTAIN") self.assertIn( "核对设备已断开", run.diagnostics_json["reconciliation"]["error_message"],