fix: 等待微信返回后再只读核单 (#124)
This commit is contained in:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from collections import Counter
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
from typing import Any, Callable, Optional
|
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 ""
|
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:
|
def _label_count(root: ET.Element, targets: tuple[str, ...]) -> int:
|
||||||
return sum(
|
return sum(
|
||||||
1
|
1
|
||||||
@@ -329,7 +350,10 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|||||||
device_service: Optional[PddDeviceService] = None,
|
device_service: Optional[PddDeviceService] = None,
|
||||||
cancelled: Callable[[], bool] = lambda: False,
|
cancelled: Callable[[], bool] = lambda: False,
|
||||||
settle_seconds: float = 0.4,
|
settle_seconds: float = 0.4,
|
||||||
|
transition_timeout_seconds: float = 30.0,
|
||||||
max_pages: int = 5,
|
max_pages: int = 5,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._device_address = str(device_address or "").strip()
|
self._device_address = str(device_address or "").strip()
|
||||||
self._device_service = (
|
self._device_service = (
|
||||||
@@ -339,7 +363,12 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|||||||
)
|
)
|
||||||
self._cancelled = cancelled
|
self._cancelled = cancelled
|
||||||
self._settle_seconds = max(0.0, float(settle_seconds))
|
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._max_pages = max(1, int(max_pages))
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._sleep = sleep
|
||||||
self._session = None
|
self._session = None
|
||||||
self._device = None
|
self._device = None
|
||||||
|
|
||||||
@@ -431,23 +460,39 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|||||||
return self._device
|
return self._device
|
||||||
|
|
||||||
def _open_unpaid_orders(self, device: Any) -> None:
|
def _open_unpaid_orders(self, device: Any) -> None:
|
||||||
root = _parse_xml(device.dump_hierarchy(compressed=False))
|
deadline = self._monotonic() + self._transition_timeout_seconds
|
||||||
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()
|
|
||||||
|
|
||||||
opened_order_area = False
|
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()
|
self._check_cancelled()
|
||||||
root = _parse_xml(device.dump_hierarchy(compressed=False))
|
root = _parse_xml(device.dump_hierarchy(compressed=False))
|
||||||
|
if self._monotonic() > deadline:
|
||||||
|
last_reason = "读取手机控件树超时"
|
||||||
|
break
|
||||||
if not _package_hint_from_tree(root):
|
if not _package_hint_from_tree(root):
|
||||||
current = device.app_current()
|
if not checked_foreground:
|
||||||
if str(current.get("package") or "") != PDD_PACKAGE_NAME:
|
current_package = _foreground_package_from_tree(root)
|
||||||
raise PddPurchaseReconcileError(
|
if not current_package:
|
||||||
"RECONCILE_WRONG_APP", "核单时 PDD 不在前台"
|
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)
|
self._raise_for_special_page(root)
|
||||||
combined = _subtree_text(root)
|
combined = _subtree_text(root)
|
||||||
if _is_single_order_detail(root) or (
|
if _is_single_order_detail(root) or (
|
||||||
@@ -460,27 +505,44 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS):
|
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()
|
self._settle()
|
||||||
continue
|
continue
|
||||||
if "我的订单" in combined:
|
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
|
opened_order_area = True
|
||||||
|
last_reason = "已打开待付款,等待订单列表加载"
|
||||||
self._settle()
|
self._settle()
|
||||||
continue
|
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
|
opened_order_area = True
|
||||||
|
last_reason = "已打开我的订单,等待待付款入口加载"
|
||||||
self._settle()
|
self._settle()
|
||||||
continue
|
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()
|
self._settle()
|
||||||
continue
|
continue
|
||||||
raise PddPurchaseReconcileError(
|
last_reason = "PDD 页面仍在过渡,尚未出现唯一只读订单入口"
|
||||||
"RECONCILE_ORDER_ENTRY_NOT_FOUND",
|
self._settle()
|
||||||
"没有找到唯一的“个人中心/我的订单/待付款”只读入口",
|
|
||||||
)
|
|
||||||
raise PddPurchaseReconcileError(
|
raise PddPurchaseReconcileError(
|
||||||
"RECONCILE_ORDER_PAGE_TIMEOUT", "打开待付款订单列表超时"
|
"RECONCILE_ORDER_PAGE_TIMEOUT",
|
||||||
|
f"等待 PDD 待付款页稳定超时:{last_reason}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -534,7 +596,7 @@ class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
|||||||
|
|
||||||
def _settle(self) -> None:
|
def _settle(self) -> None:
|
||||||
if self._settle_seconds:
|
if self._settle_seconds:
|
||||||
time.sleep(self._settle_seconds)
|
self._sleep(self._settle_seconds)
|
||||||
|
|
||||||
|
|
||||||
def create_u2_purchase_reconcile_adapter(
|
def create_u2_purchase_reconcile_adapter(
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ class PurchaseReconcileService:
|
|||||||
return self._save_manual_review(
|
return self._save_manual_review(
|
||||||
task, run, "ambiguous", diagnostics
|
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)
|
return self._save_manual_review(task, run, match_status, diagnostics)
|
||||||
|
|
||||||
def _read_candidates(
|
def _read_candidates(
|
||||||
@@ -153,9 +156,13 @@ class PurchaseReconcileService:
|
|||||||
):
|
):
|
||||||
raise TypeError("采购核对候选数据无效")
|
raise TypeError("采购核对候选数据无效")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
error_code = str(
|
||||||
|
getattr(exc, "code", "RECONCILE_READ_FAILED")
|
||||||
|
or "RECONCILE_READ_FAILED"
|
||||||
|
)
|
||||||
scan = PurchaseReconcileScan(
|
scan = PurchaseReconcileScan(
|
||||||
diagnostics={
|
diagnostics={
|
||||||
"error_code": "RECONCILE_READ_FAILED",
|
"error_code": error_code,
|
||||||
"error_message": str(exc)[:300],
|
"error_message": str(exc)[:300],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -181,13 +188,16 @@ class PurchaseReconcileService:
|
|||||||
dict(diagnostics),
|
dict(diagnostics),
|
||||||
)
|
)
|
||||||
messages = {
|
messages = {
|
||||||
"not_found": "未找到符合时间范围的订单",
|
"not_found": "未找到符合条件的订单",
|
||||||
"ambiguous": "找到多个完全匹配的未付款订单",
|
"ambiguous": "找到多个完全匹配的未付款订单",
|
||||||
"unknown": "订单字段不完整、不一致或读取失败",
|
"unknown": "订单字段不完整、不一致或读取失败",
|
||||||
}
|
}
|
||||||
|
message = messages[match_status]
|
||||||
|
if match_status == "unknown" and diagnostics.get("error_message"):
|
||||||
|
message = f"订单读取失败:{str(diagnostics['error_message'])[:200]}"
|
||||||
return PurchaseReconcileOutcome(
|
return PurchaseReconcileOutcome(
|
||||||
"manual_review",
|
"manual_review",
|
||||||
f"任务 {task.remote_task_id} {messages[match_status]};需人工处理,绝不重新下单",
|
f"任务 {task.remote_task_id} {message};需人工处理,绝不重新下单",
|
||||||
task.remote_task_id,
|
task.remote_task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from src.pdd_purchase_reconcile_adapter import (
|
|||||||
PurchaseReconcileQuery,
|
PurchaseReconcileQuery,
|
||||||
)
|
)
|
||||||
from src.pdd_u2_purchase_reconcile_adapter import (
|
from src.pdd_u2_purchase_reconcile_adapter import (
|
||||||
|
PddPurchaseReconcileError,
|
||||||
U2PddPurchaseReconcileAdapter,
|
U2PddPurchaseReconcileAdapter,
|
||||||
_is_single_order_detail,
|
_is_single_order_detail,
|
||||||
_merge_order_evidence,
|
_merge_order_evidence,
|
||||||
@@ -96,6 +97,51 @@ class DetailDevice:
|
|||||||
self.page_index = min(self.page_index + 1, len(self.pages) - 1)
|
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 (
|
||||||
|
'<hierarchy>'
|
||||||
|
f'<node package="{package}" text="{text}" />'
|
||||||
|
f'<node package="{package}" text="" />'
|
||||||
|
f'<node package="{package}" text="" />'
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
|
||||||
def test_parser_extracts_only_required_unpaid_order_fields(self):
|
def test_parser_extracts_only_required_unpaid_order_fields(self):
|
||||||
candidates = parse_order_candidates(
|
candidates = parse_order_candidates(
|
||||||
@@ -202,6 +248,132 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(_is_single_order_detail(root))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -327,11 +327,14 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
).execute_one()
|
).execute_one()
|
||||||
|
|
||||||
self.assertEqual(outcome.kind, "manual_review")
|
self.assertEqual(outcome.kind, "manual_review")
|
||||||
|
self.assertIn("核对设备已断开", outcome.message)
|
||||||
|
self.assertNotIn("时间范围", outcome.message)
|
||||||
self.assertEqual(purchase_calls, [])
|
self.assertEqual(purchase_calls, [])
|
||||||
detail = self.repository.get_task(task_id)
|
detail = self.repository.get_task(task_id)
|
||||||
run = self.repository.latest_task_run(task_id)
|
run = self.repository.latest_task_run(task_id)
|
||||||
assert detail is not None and run is not None
|
assert detail is not None and run is not None
|
||||||
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
self.assertEqual(detail.current_step, "reconcile_manual_review")
|
||||||
|
self.assertEqual(run.error_code, "ORDER_MATCH_UNCERTAIN")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"核对设备已断开",
|
"核对设备已断开",
|
||||||
run.diagnostics_json["reconciliation"]["error_message"],
|
run.diagnostics_json["reconciliation"]["error_message"],
|
||||||
|
|||||||
Reference in New Issue
Block a user