feat: 完整采集采购尺码候选 (#256)
This commit is contained in:
@@ -1182,6 +1182,15 @@ class PddCollectService:
|
|||||||
artifacts=tuple(self._artifacts),
|
artifacts=tuple(self._artifacts),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def collect_second_dimension_candidates(
|
||||||
|
self, device: Any
|
||||||
|
) -> Optional[SpecDimension]:
|
||||||
|
"""复用采集流程完整遍历当前颜色的第二规格,不点击任何选项。"""
|
||||||
|
|
||||||
|
return self._collect_size_dimension(
|
||||||
|
device, require_confirmed_edges=True
|
||||||
|
)
|
||||||
|
|
||||||
def _check_cancelled(self) -> None:
|
def _check_cancelled(self) -> None:
|
||||||
if self._cancelled():
|
if self._cancelled():
|
||||||
raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消")
|
raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消")
|
||||||
@@ -1926,7 +1935,12 @@ class PddCollectService:
|
|||||||
break
|
break
|
||||||
return latest_xml
|
return latest_xml
|
||||||
|
|
||||||
def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]:
|
def _collect_size_dimension(
|
||||||
|
self,
|
||||||
|
device: Any,
|
||||||
|
*,
|
||||||
|
require_confirmed_edges: bool = False,
|
||||||
|
) -> Optional[SpecDimension]:
|
||||||
"""完整遍历第二规格并收集文字,全程不点击尺码或套餐。"""
|
"""完整遍历第二规格并收集文字,全程不点击尺码或套餐。"""
|
||||||
|
|
||||||
sizes: dict[str, bool] = {}
|
sizes: dict[str, bool] = {}
|
||||||
@@ -2008,7 +2022,10 @@ class PddCollectService:
|
|||||||
continuation = self._build_second_dimension_context(
|
continuation = self._build_second_dimension_context(
|
||||||
xml_data, initial_size_dimension
|
xml_data, initial_size_dimension
|
||||||
)
|
)
|
||||||
strict_edge_check = "套餐" in size_name and self._max_spec_swipes > 0
|
strict_edge_check = (
|
||||||
|
require_confirmed_edges
|
||||||
|
or ("套餐" in size_name and self._max_spec_swipes > 0)
|
||||||
|
)
|
||||||
if strict_edge_check and not start_confirmed:
|
if strict_edge_check and not start_confirmed:
|
||||||
raise PddCollectError(
|
raise PddCollectError(
|
||||||
"PDD_DATA_SPEC_INCOMPLETE",
|
"PDD_DATA_SPEC_INCOMPLETE",
|
||||||
@@ -2051,6 +2068,16 @@ class PddCollectService:
|
|||||||
option_text = self._second_dimension_option_text(value.text)
|
option_text = self._second_dimension_option_text(value.text)
|
||||||
if option_text is None:
|
if option_text is None:
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
require_confirmed_edges
|
||||||
|
and option_text in sizes
|
||||||
|
and sizes[option_text] != value.available
|
||||||
|
):
|
||||||
|
raise PddCollectError(
|
||||||
|
"PDD_DATA_SPEC_INCOMPLETE",
|
||||||
|
f"完整遍历时第二规格状态发生变化:{size_name}",
|
||||||
|
{"dimension_name": size_name},
|
||||||
|
)
|
||||||
sizes[option_text] = sizes.get(option_text, False) or value.available
|
sizes[option_text] = sizes.get(option_text, False) or value.available
|
||||||
|
|
||||||
signature = self._second_dimension_signature(xml_data, size_dimension)
|
signature = self._second_dimension_signature(xml_data, size_dimension)
|
||||||
|
|||||||
@@ -6,9 +6,128 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Mapping
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_frame(value: str) -> str:
|
||||||
|
"""按接口契约生成带 UTF-8 字节长度的哈希片段。"""
|
||||||
|
|
||||||
|
return f"{len(value.encode('utf-8'))}:{value}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurchaseSizeObservation:
|
||||||
|
"""完整遍历时看到的一条第二规格,包含不可购买项。"""
|
||||||
|
|
||||||
|
raw_text: str
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurchaseSpecCandidate:
|
||||||
|
"""可以交给 Admin 解析的一条可购买尺码候选。"""
|
||||||
|
|
||||||
|
candidate_id: str
|
||||||
|
raw_text: str
|
||||||
|
options: Mapping[str, str]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"candidate_id": self.candidate_id,
|
||||||
|
"raw_text": self.raw_text,
|
||||||
|
"options": dict(self.options),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurchaseSpecCandidateSnapshot:
|
||||||
|
"""当前商品、已选颜色下,一次完整且稳定的尺码候选观察。"""
|
||||||
|
|
||||||
|
goods_id: str
|
||||||
|
selected_color: str
|
||||||
|
target_size: str
|
||||||
|
dimension_name: str
|
||||||
|
observations: tuple[PurchaseSizeObservation, ...]
|
||||||
|
candidates: tuple[PurchaseSpecCandidate, ...]
|
||||||
|
candidate_snapshot_hash: str
|
||||||
|
observed_at: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
goods_id: str,
|
||||||
|
selected_color: str,
|
||||||
|
target_size: str,
|
||||||
|
dimension_name: str,
|
||||||
|
observations: Sequence[PurchaseSizeObservation],
|
||||||
|
observed_at: str,
|
||||||
|
) -> "PurchaseSpecCandidateSnapshot":
|
||||||
|
"""按页面顺序生成候选短编号和 spec-resolution-v1 哈希。"""
|
||||||
|
|
||||||
|
checked_observations = tuple(observations)
|
||||||
|
candidates = tuple(
|
||||||
|
PurchaseSpecCandidate(
|
||||||
|
candidate_id=f"c{index}",
|
||||||
|
raw_text=item.raw_text,
|
||||||
|
options={"color": selected_color, "size": item.raw_text},
|
||||||
|
)
|
||||||
|
for index, item in enumerate(
|
||||||
|
(item for item in checked_observations if item.available),
|
||||||
|
start=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
material = "".join(
|
||||||
|
(
|
||||||
|
_hash_frame("spec-resolution-v1"),
|
||||||
|
_hash_frame(goods_id),
|
||||||
|
_hash_frame(selected_color),
|
||||||
|
_hash_frame(str(len(candidates))),
|
||||||
|
*(
|
||||||
|
"".join(
|
||||||
|
(
|
||||||
|
_hash_frame(item.candidate_id),
|
||||||
|
_hash_frame(item.raw_text),
|
||||||
|
_hash_frame(str(item.options["color"])),
|
||||||
|
_hash_frame(str(item.options["size"])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in candidates
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
goods_id=goods_id,
|
||||||
|
selected_color=selected_color,
|
||||||
|
target_size=target_size,
|
||||||
|
dimension_name=dimension_name,
|
||||||
|
observations=checked_observations,
|
||||||
|
candidates=candidates,
|
||||||
|
candidate_snapshot_hash=hashlib.sha256(
|
||||||
|
material.encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
"""返回确定顺序的内存结构;调用方不得把它直接写入日志。"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"goods_id": self.goods_id,
|
||||||
|
"selected_color": self.selected_color,
|
||||||
|
"target_size": self.target_size,
|
||||||
|
"dimension_name": self.dimension_name,
|
||||||
|
"observations": [
|
||||||
|
{"raw_text": item.raw_text, "available": item.available}
|
||||||
|
for item in self.observations
|
||||||
|
],
|
||||||
|
"candidates": [item.to_dict() for item in self.candidates],
|
||||||
|
"candidate_snapshot_hash": self.candidate_snapshot_hash,
|
||||||
|
"observed_at": self.observed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -46,6 +165,24 @@ class PddPurchaseError(RuntimeError):
|
|||||||
self.diagnostics = dict(diagnostics or {})
|
self.diagnostics = dict(diagnostics or {})
|
||||||
|
|
||||||
|
|
||||||
|
class PddPurchaseSpecResolutionRequired(PddPurchaseError):
|
||||||
|
"""本地无法精确匹配,但已得到完整可购买候选快照。"""
|
||||||
|
|
||||||
|
def __init__(self, snapshot: PurchaseSpecCandidateSnapshot) -> None:
|
||||||
|
super().__init__(
|
||||||
|
"PURCHASE_SPEC_RESOLUTION_REQUIRED",
|
||||||
|
f"目标尺码无法精确匹配,需要解析候选:{snapshot.target_size}",
|
||||||
|
step="purchase_select_options",
|
||||||
|
diagnostics={
|
||||||
|
"selection_failure": "target_not_visible",
|
||||||
|
"candidate_count": len(snapshot.candidates),
|
||||||
|
"candidate_snapshot_hash": snapshot.candidate_snapshot_hash,
|
||||||
|
"dimension_name": snapshot.dimension_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
class PddPurchaseAdapter(ABC):
|
class PddPurchaseAdapter(ABC):
|
||||||
"""一台设备的一次采购演练会话;只能由创建它的工作线程使用。"""
|
"""一台设备的一次采购演练会话;只能由创建它的工作线程使用。"""
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Mapping, Optional
|
from typing import Any, Callable, Mapping, Optional
|
||||||
@@ -24,6 +25,11 @@ from .pdd_device_service import (
|
|||||||
current_thread_device_service,
|
current_thread_device_service,
|
||||||
)
|
)
|
||||||
from .performance_timing import current_performance_trace
|
from .performance_timing import current_performance_trace
|
||||||
|
from .pdd_collect_service import (
|
||||||
|
PddCollectError,
|
||||||
|
PddCollectService,
|
||||||
|
SpecDimension,
|
||||||
|
)
|
||||||
from .pdd_page_classifier import (
|
from .pdd_page_classifier import (
|
||||||
ACTION_NETWORK_ERROR,
|
ACTION_NETWORK_ERROR,
|
||||||
ACTION_READY,
|
ACTION_READY,
|
||||||
@@ -46,11 +52,15 @@ from .pdd_purchase_adapter import (
|
|||||||
PddLivePurchaseAdapter,
|
PddLivePurchaseAdapter,
|
||||||
PddPurchaseAdapter,
|
PddPurchaseAdapter,
|
||||||
PddPurchaseError,
|
PddPurchaseError,
|
||||||
|
PddPurchaseSpecResolutionRequired,
|
||||||
|
PurchaseSizeObservation,
|
||||||
|
PurchaseSpecCandidateSnapshot,
|
||||||
PurchasePageState,
|
PurchasePageState,
|
||||||
)
|
)
|
||||||
from .util.get_size_panle_coord import get_size_panel_coord
|
from .util.get_size_panle_coord import get_size_panel_coord
|
||||||
from .util.select_color_size import (
|
from .util.select_color_size import (
|
||||||
color_selection_failure_reason,
|
color_selection_failure_reason,
|
||||||
|
normalize_spec_text,
|
||||||
select_color,
|
select_color,
|
||||||
select_size,
|
select_size,
|
||||||
size_selection_failure_reason,
|
size_selection_failure_reason,
|
||||||
@@ -890,6 +900,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
panel_timeout: float = 10.0,
|
panel_timeout: float = 10.0,
|
||||||
select_color_fn: Callable[..., bool] = select_color,
|
select_color_fn: Callable[..., bool] = select_color,
|
||||||
select_size_fn: Callable[..., bool] = select_size,
|
select_size_fn: Callable[..., bool] = select_size,
|
||||||
|
size_candidate_collector: Optional[
|
||||||
|
Callable[[Any], Optional[SpecDimension]]
|
||||||
|
] = None,
|
||||||
|
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||||
artifact_directory: Optional[Path] = None,
|
artifact_directory: Optional[Path] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._device_address = str(device_address or "").strip()
|
self._device_address = str(device_address or "").strip()
|
||||||
@@ -901,6 +915,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
self._panel_timeout = panel_timeout
|
self._panel_timeout = panel_timeout
|
||||||
self._select_color = select_color_fn
|
self._select_color = select_color_fn
|
||||||
self._select_size = select_size_fn
|
self._select_size = select_size_fn
|
||||||
|
self._size_candidate_collector = (
|
||||||
|
size_candidate_collector or self._collect_size_candidates
|
||||||
|
)
|
||||||
|
self._now = now
|
||||||
self._artifact_directory = artifact_directory
|
self._artifact_directory = artifact_directory
|
||||||
self._last_xml: Optional[str] = None
|
self._last_xml: Optional[str] = None
|
||||||
self._hierarchy_reads = 0
|
self._hierarchy_reads = 0
|
||||||
@@ -1023,6 +1041,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
panel_xml = self._wait_for_confirmation_panel()
|
panel_xml = self._wait_for_confirmation_panel()
|
||||||
|
|
||||||
color = checked.get("color")
|
color = checked.get("color")
|
||||||
|
selected_color_for_resolution = ""
|
||||||
if color:
|
if color:
|
||||||
panel_xml = self._restore_purchase_panel_color_region(panel_xml)
|
panel_xml = self._restore_purchase_panel_color_region(panel_xml)
|
||||||
trace = current_performance_trace()
|
trace = current_performance_trace()
|
||||||
@@ -1065,6 +1084,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
step="purchase_select_options",
|
step="purchase_select_options",
|
||||||
diagnostics=diagnostics,
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
|
selected_color_for_resolution = color
|
||||||
size = checked.get("size")
|
size = checked.get("size")
|
||||||
if size:
|
if size:
|
||||||
latest_xml = self._dump_hierarchy()
|
latest_xml = self._dump_hierarchy()
|
||||||
@@ -1082,6 +1102,15 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
failure_reason = size_selection_failure_reason(
|
failure_reason = size_selection_failure_reason(
|
||||||
failed_xml, size
|
failed_xml, size
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
failure_reason == "target_not_visible"
|
||||||
|
and selected_color_for_resolution
|
||||||
|
):
|
||||||
|
self._raise_spec_resolution_required(
|
||||||
|
failed_xml,
|
||||||
|
selected_color_for_resolution,
|
||||||
|
size,
|
||||||
|
)
|
||||||
messages = {
|
messages = {
|
||||||
"target_not_visible": f"没有找到目标尺码:{size}",
|
"target_not_visible": f"没有找到目标尺码:{size}",
|
||||||
"target_ambiguous": (
|
"target_ambiguous": (
|
||||||
@@ -1105,6 +1134,99 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._raise_device_or_page_error(exc, "purchase_select_options")
|
self._raise_device_or_page_error(exc, "purchase_select_options")
|
||||||
|
|
||||||
|
def _raise_spec_resolution_required(
|
||||||
|
self,
|
||||||
|
failed_xml: str,
|
||||||
|
selected_color: str,
|
||||||
|
target_size: str,
|
||||||
|
) -> None:
|
||||||
|
"""完整只读遍历后,仅为页面确实不存在的目标返回候选快照。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._restore_purchase_panel_color_region(failed_xml)
|
||||||
|
dimension = self._size_candidate_collector(self._require_device())
|
||||||
|
except PddPurchaseError:
|
||||||
|
raise
|
||||||
|
except PddCollectError as exc:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE",
|
||||||
|
f"目标尺码未找到,且无法完整读取页面候选:{exc.message}",
|
||||||
|
step="purchase_select_options",
|
||||||
|
diagnostics={
|
||||||
|
"selection_failure": "target_not_visible",
|
||||||
|
"candidate_scan_error": exc.code,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
self._raise_device_or_page_error(exc, "purchase_select_options")
|
||||||
|
|
||||||
|
if dimension is None or not dimension.values:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE",
|
||||||
|
"目标尺码未找到,且页面没有可确认完整的第二规格候选",
|
||||||
|
step="purchase_select_options",
|
||||||
|
diagnostics={"selection_failure": "target_not_visible"},
|
||||||
|
)
|
||||||
|
|
||||||
|
available_values = tuple(
|
||||||
|
item for item in dimension.values if item.available
|
||||||
|
)
|
||||||
|
if not available_values:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_OPTIONS_MISMATCH",
|
||||||
|
f"当前颜色没有可购买的尺码候选:{selected_color}",
|
||||||
|
step="purchase_select_options",
|
||||||
|
diagnostics={"selection_failure": "target_not_visible"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 目标若在完整列表中精确或繁简等价出现,说明前面的点击/确认失败,
|
||||||
|
# 不能把它伪装成需要远端解析的问题。
|
||||||
|
normalized_target = normalize_spec_text(target_size)
|
||||||
|
if any(
|
||||||
|
item.text == target_size
|
||||||
|
or normalize_spec_text(item.text) == normalized_target
|
||||||
|
for item in available_values
|
||||||
|
):
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_OPTIONS_MISMATCH",
|
||||||
|
f"页面存在目标尺码,但没有可靠确认选中:{target_size}",
|
||||||
|
step="purchase_select_options",
|
||||||
|
diagnostics={"selection_failure": "selection_unconfirmed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
observed_at = (
|
||||||
|
self._now()
|
||||||
|
.astimezone(timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
snapshot = PurchaseSpecCandidateSnapshot.build(
|
||||||
|
goods_id=self._goods_id,
|
||||||
|
selected_color=selected_color,
|
||||||
|
target_size=target_size,
|
||||||
|
dimension_name=dimension.name,
|
||||||
|
observations=tuple(
|
||||||
|
PurchaseSizeObservation(item.text, item.available)
|
||||||
|
for item in dimension.values
|
||||||
|
),
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
raise PddPurchaseSpecResolutionRequired(snapshot)
|
||||||
|
|
||||||
|
def _collect_size_candidates(self, device: Any) -> Optional[SpecDimension]:
|
||||||
|
"""使用采集模块同一套第二规格遍历,不建立新连接也不保存 XML。"""
|
||||||
|
|
||||||
|
service = PddCollectService(
|
||||||
|
self._device_service,
|
||||||
|
self._device_address,
|
||||||
|
"purchase-runtime",
|
||||||
|
sleeper=self._sleep,
|
||||||
|
monotonic=self._monotonic,
|
||||||
|
cancelled=self._cancelled,
|
||||||
|
artifact_directory=None,
|
||||||
|
)
|
||||||
|
return service.collect_second_dimension_candidates(device)
|
||||||
|
|
||||||
def set_quantity(self, quantity: int) -> None:
|
def set_quantity(self, quantity: int) -> None:
|
||||||
device = self._require_device()
|
device = self._require_device()
|
||||||
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
|
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
|
||||||
|
|||||||
@@ -853,7 +853,7 @@ class PddCollectParserTest(unittest.TestCase):
|
|||||||
sleeper=lambda _seconds: None,
|
sleeper=lambda _seconds: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
dimension = service._collect_size_dimension(device)
|
dimension = service.collect_second_dimension_candidates(device)
|
||||||
|
|
||||||
self.assertIsNotNone(dimension)
|
self.assertIsNotNone(dimension)
|
||||||
self.assertEqual(dimension.name, "尺码")
|
self.assertEqual(dimension.name, "尺码")
|
||||||
@@ -1499,7 +1499,7 @@ class PddCollectParserTest(unittest.TestCase):
|
|||||||
sleeper=lambda _seconds: None,
|
sleeper=lambda _seconds: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = service._collect_size_dimension(device)
|
result = service.collect_second_dimension_candidates(device)
|
||||||
|
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
self.assertEqual(result.name, "套餐(7)")
|
self.assertEqual(result.name, "套餐(7)")
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
|
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from src.pdd_device_service import PddDeviceService
|
from src.pdd_device_service import PddDeviceService
|
||||||
from src.pdd_purchase_adapter import PddPurchaseError
|
from src.pdd_collect_service import DimensionValue, PddCollectError, SpecDimension
|
||||||
|
from src.pdd_purchase_adapter import (
|
||||||
|
PddPurchaseError,
|
||||||
|
PddPurchaseSpecResolutionRequired,
|
||||||
|
)
|
||||||
from src.performance_timing import TaskPerformanceTrace
|
from src.performance_timing import TaskPerformanceTrace
|
||||||
from src.pdd_u2_purchase_adapter import (
|
from src.pdd_u2_purchase_adapter import (
|
||||||
U2PddLivePurchaseAdapter,
|
U2PddLivePurchaseAdapter,
|
||||||
@@ -922,6 +927,150 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(device.editor_values, [])
|
self.assertEqual(device.editor_values, [])
|
||||||
self.assertEqual(device.app_wait_calls, 0)
|
self.assertEqual(device.app_wait_calls, 0)
|
||||||
|
|
||||||
|
def test_missing_size_returns_complete_structured_candidate_snapshot(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
collected = []
|
||||||
|
|
||||||
|
def collect_candidates(current_device):
|
||||||
|
collected.append(current_device)
|
||||||
|
return SpecDimension(
|
||||||
|
"size",
|
||||||
|
"尺码",
|
||||||
|
(
|
||||||
|
DimensionValue("M", True),
|
||||||
|
DimensionValue("L(售罄)", False),
|
||||||
|
DimensionValue("XL", True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
select_color_fn=lambda *_args, **_kwargs: True,
|
||||||
|
select_size_fn=lambda *_args, **_kwargs: False,
|
||||||
|
size_candidate_collector=collect_candidates,
|
||||||
|
now=lambda: datetime(2026, 8, 17, 8, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseSpecResolutionRequired) as raised:
|
||||||
|
adapter.select_options({"color": "黑色", "size": "XXL"})
|
||||||
|
|
||||||
|
snapshot = raised.exception.snapshot
|
||||||
|
self.assertEqual(collected, [device])
|
||||||
|
self.assertEqual(snapshot.goods_id, "753136429979")
|
||||||
|
self.assertEqual(snapshot.dimension_name, "尺码")
|
||||||
|
self.assertEqual(snapshot.observed_at, "2026-08-17T08:00:00Z")
|
||||||
|
self.assertEqual(
|
||||||
|
[(item.raw_text, item.available) for item in snapshot.observations],
|
||||||
|
[("M", True), ("L(售罄)", False), ("XL", True)],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[item.to_dict() for item in snapshot.candidates],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"candidate_id": "c1",
|
||||||
|
"raw_text": "M",
|
||||||
|
"options": {"color": "黑色", "size": "M"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"candidate_id": "c2",
|
||||||
|
"raw_text": "XL",
|
||||||
|
"options": {"color": "黑色", "size": "XL"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot.candidate_snapshot_hash,
|
||||||
|
"fcc35e4429625c830e6b2cbde9c618b63536339c18d9d452c34ec8347d1f1630",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.diagnostics["candidate_snapshot_hash"],
|
||||||
|
snapshot.candidate_snapshot_hash,
|
||||||
|
)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_candidate_snapshot_is_not_used_when_target_exists_but_click_fails(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
select_color_fn=lambda *_args, **_kwargs: True,
|
||||||
|
select_size_fn=lambda *_args, **_kwargs: False,
|
||||||
|
size_candidate_collector=lambda _device: SpecDimension(
|
||||||
|
"size", "尺码", (DimensionValue("XXL", True),)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.select_options({"color": "黑色", "size": "XXL"})
|
||||||
|
|
||||||
|
self.assertNotIsInstance(
|
||||||
|
raised.exception, PddPurchaseSpecResolutionRequired
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.code, "PURCHASE_OPTIONS_MISMATCH")
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.diagnostics["selection_failure"],
|
||||||
|
"selection_unconfirmed",
|
||||||
|
)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_incomplete_candidate_scan_remains_normal_purchase_failure(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
|
||||||
|
def incomplete_scan(_device):
|
||||||
|
raise PddCollectError(
|
||||||
|
"PDD_DATA_SPEC_INCOMPLETE", "达到滑动上限"
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
select_color_fn=lambda *_args, **_kwargs: True,
|
||||||
|
select_size_fn=lambda *_args, **_kwargs: False,
|
||||||
|
size_candidate_collector=incomplete_scan,
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.select_options({"color": "黑色", "size": "XXL"})
|
||||||
|
|
||||||
|
self.assertNotIsInstance(
|
||||||
|
raised.exception, PddPurchaseSpecResolutionRequired
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.code,
|
||||||
|
"PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.diagnostics["candidate_scan_error"],
|
||||||
|
"PDD_DATA_SPEC_INCOMPLETE",
|
||||||
|
)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_size_without_selected_color_never_requests_remote_resolution(self):
|
||||||
|
device = FakeDevice()
|
||||||
|
scans = []
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=lambda _seconds: None,
|
||||||
|
select_size_fn=lambda *_args, **_kwargs: False,
|
||||||
|
size_candidate_collector=lambda _device: scans.append(True),
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.select_options({"size": "XXL"})
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.code, "PURCHASE_OPTIONS_MISMATCH")
|
||||||
|
self.assertEqual(scans, [])
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_contextual_confirm_panel_is_reliable_without_clicking_confirm(self):
|
def test_contextual_confirm_panel_is_reliable_without_clicking_confirm(self):
|
||||||
device = ContextualConfirmPanelDevice()
|
device = ContextualConfirmPanelDevice()
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ Client 应执行:
|
|||||||
6. 获取并核对订单编号和下单时间。
|
6. 获取并核对订单编号和下单时间。
|
||||||
7. 保存本地结果并提交 Admin。
|
7. 保存本地结果并提交 Admin。
|
||||||
|
|
||||||
|
规格选择先按页面原文精确匹配,再允许繁体、简体等价文字唯一匹配;等价候选不唯一时
|
||||||
|
必须停止。若商品正确、颜色已经可靠选中,但目标尺码在完整可购买列表中确实不存在,
|
||||||
|
Client 应只读遍历当前颜色的全部第二规格,保留规格名称、页面原文、可用状态和页面顺序,
|
||||||
|
生成稳定候选编号及快照哈希,交给后续受审计的规格解析流程。遍历未到边界、页面丢失、
|
||||||
|
候选发生变化,或页面中其实存在目标尺码但点击确认失败时,仍按普通采购失败处理,不能
|
||||||
|
请求远端猜测。本阶段候选只保存在当前执行内存,不写入 SQLite、日志或控件树产物。
|
||||||
|
|
||||||
地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、
|
地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、
|
||||||
详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在
|
详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在
|
||||||
不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话,
|
不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话,
|
||||||
|
|||||||
@@ -422,10 +422,19 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
|||||||
`ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的
|
`ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的
|
||||||
`PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层
|
`PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层
|
||||||
Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
||||||
使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD
|
先使用原文精确匹配,再使用繁体、简体规范化结果做唯一匹配;其他动态维度直接停止,
|
||||||
|
不做模糊相似匹配。原生 PDD
|
||||||
控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的
|
控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的
|
||||||
PDD URL,包名和页面类型仍以最新控件树确认。
|
PDD URL,包名和页面类型仍以最新控件树确认。
|
||||||
|
|
||||||
|
颜色已可靠选中但尺码确实不在页面时,采购 Adapter 调用
|
||||||
|
`PddCollectService.collect_second_dimension_candidates`,复用采集侧经过真机验证的
|
||||||
|
第二规格只读遍历。遍历完成后,Adapter 在内存中保留全部观察项及可用状态,并仅为
|
||||||
|
可购买项按页面顺序生成 `c1`、`c2` 等候选和 `spec-resolution-v1` 快照哈希。只有完整
|
||||||
|
遍历才能抛出 `PddPurchaseSpecResolutionRequired`;遍历不完整、页面丢失、没有可购买项,
|
||||||
|
或完整列表中仍存在目标的精确/繁简等价文字,都返回普通采购错误。该 Adapter 不调用
|
||||||
|
Admin,也不点击任何远端解析结果;网络请求和解析后复核属于后续工单。
|
||||||
|
|
||||||
部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的
|
部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的
|
||||||
订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation`
|
订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation`
|
||||||
和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。
|
和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。
|
||||||
|
|||||||
Reference in New Issue
Block a user