feat: 完善真实采购订单核对与恢复 (#100)
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Mapping, Optional
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -15,33 +15,42 @@ class PurchaseReconcileQuery:
|
|||||||
goods_id: str
|
goods_id: str
|
||||||
options: Mapping[str, str]
|
options: Mapping[str, str]
|
||||||
quantity: int
|
quantity: int
|
||||||
|
unit_price_cent: int
|
||||||
|
total_price_cent: int
|
||||||
irreversible_action_at: str
|
irreversible_action_at: str
|
||||||
|
reconcile_started_at: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PurchaseReconcileObservation:
|
class PurchaseOrderCandidate:
|
||||||
"""只读核对看到的结果。"""
|
"""订单列表中一个不含个人信息的只读候选。"""
|
||||||
|
|
||||||
match_status: str
|
order_no: str = ""
|
||||||
order_no: Optional[str] = None
|
goods_id: str = ""
|
||||||
ordered_at: Optional[str] = None
|
options: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
quantity: int = 0
|
||||||
|
total_price_cent: int = 0
|
||||||
|
ordered_at: str = ""
|
||||||
|
ordered_at_raw: str = ""
|
||||||
|
payment_status: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PurchaseReconcileScan:
|
||||||
|
"""一次只读扫描结果;diagnostics 只能包含计数和错误摘要。"""
|
||||||
|
|
||||||
|
candidates: Sequence[PurchaseOrderCandidate] = field(default_factory=tuple)
|
||||||
diagnostics: Mapping[str, object] = field(default_factory=dict)
|
diagnostics: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.match_status not in {
|
|
||||||
"matched", "not_found", "ambiguous", "unknown"
|
|
||||||
}:
|
|
||||||
raise ValueError("采购核对结果无效")
|
|
||||||
|
|
||||||
|
|
||||||
class PddPurchaseReconcileAdapter(ABC):
|
class PddPurchaseReconcileAdapter(ABC):
|
||||||
"""已进入不可逆阶段后使用的只读订单核对会话。"""
|
"""已进入不可逆阶段后使用的只读订单核对会话。"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def read_order_match(
|
def read_order_candidates(
|
||||||
self, query: PurchaseReconcileQuery
|
self, query: PurchaseReconcileQuery
|
||||||
) -> PurchaseReconcileObservation:
|
) -> PurchaseReconcileScan:
|
||||||
"""读取并核对订单候选;不得点击下单或付款。"""
|
"""只读扫描订单候选;不得提交、取消或付款。"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""uiautomator2 只读订单核对 Adapter。
|
||||||
|
|
||||||
|
只允许启动 PDD、切换到“个人中心/我的订单/待付款”、返回和滚动读取。
|
||||||
|
代码中没有提交订单、取消订单或付款入口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from .pdd_device_service import PDD_PACKAGE_NAME, PddDeviceService
|
||||||
|
from .pdd_purchase_reconcile_adapter import (
|
||||||
|
PddPurchaseReconcileAdapter,
|
||||||
|
PurchaseOrderCandidate,
|
||||||
|
PurchaseReconcileQuery,
|
||||||
|
PurchaseReconcileScan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Bounds = tuple[int, int, int, int]
|
||||||
|
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||||
|
_ORDER_NO_PATTERN = re.compile(
|
||||||
|
r"(?:订单编号|订单号)\s*[::]?\s*([A-Za-z0-9-]{6,64})"
|
||||||
|
)
|
||||||
|
_GOODS_ID_PATTERN = re.compile(
|
||||||
|
r"(?:goods_id=|商品编号\s*[::]?\s*)(\d{6,})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_QUANTITY_PATTERN = re.compile(r"(?:共\s*(\d+)\s*件|[xX×]\s*(\d+))")
|
||||||
|
_TOTAL_PATTERN = re.compile(
|
||||||
|
r"(?:需付款|应付款|合计|实付款)\s*[::]?\s*[¥¥]?\s*(\d+(?:\.\d{1,2})?)"
|
||||||
|
)
|
||||||
|
_ORDER_TIME_PATTERN = re.compile(
|
||||||
|
r"(?:下单时间|创建时间)\s*[::]?\s*"
|
||||||
|
r"(20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s+\d{1,2}:\d{2}(?::\d{2})?)"
|
||||||
|
)
|
||||||
|
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
|
||||||
|
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
|
||||||
|
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
|
||||||
|
_PAYMENT_ACTION_MARKERS = ("立即支付", "确认支付", "输入支付密码")
|
||||||
|
_UNPAID_MARKERS = ("待付款", "待支付")
|
||||||
|
_NON_UNPAID_MARKERS = ("已付款", "交易成功", "交易完成", "已取消", "退款")
|
||||||
|
_SAFE_NAVIGATION_LABELS = ("个人中心", "我的订单", "待付款")
|
||||||
|
|
||||||
|
|
||||||
|
class PddPurchaseReconcileError(RuntimeError):
|
||||||
|
"""只读核单无法安全继续。"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xml(xml_data: str | bytes) -> ET.Element:
|
||||||
|
try:
|
||||||
|
return ET.fromstring(xml_data)
|
||||||
|
except (ET.ParseError, TypeError) as exc:
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
"RECONCILE_XML_INVALID", "订单页无障碍控件树无效"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _label(node: ET.Element) -> str:
|
||||||
|
return " ".join(
|
||||||
|
value.strip()
|
||||||
|
for value in (node.get("text", ""), node.get("content-desc", ""))
|
||||||
|
if value.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subtree_text(node: ET.Element) -> str:
|
||||||
|
return " ".join(
|
||||||
|
label for item in node.iter("node") if (label := _label(item))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bounds(value: str) -> Optional[Bounds]:
|
||||||
|
match = _BOUNDS_PATTERN.fullmatch(str(value or "").strip())
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
left, top, right, bottom = map(int, match.groups())
|
||||||
|
if right <= left or bottom <= top:
|
||||||
|
return None
|
||||||
|
return left, top, right, bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_money_cent(text: str) -> int:
|
||||||
|
match = _TOTAL_PATTERN.search(text)
|
||||||
|
if match is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
value = Decimal(match.group(1)).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
except InvalidOperation:
|
||||||
|
return 0
|
||||||
|
return int(value * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_quantity(text: str) -> int:
|
||||||
|
match = _QUANTITY_PATTERN.search(text)
|
||||||
|
if match is None:
|
||||||
|
return 0
|
||||||
|
value = match.group(1) or match.group(2) or "0"
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ordered_at(text: str) -> tuple[str, str]:
|
||||||
|
match = _ORDER_TIME_PATTERN.search(text)
|
||||||
|
if match is None:
|
||||||
|
return "", ""
|
||||||
|
raw = match.group(1)
|
||||||
|
normalized = (
|
||||||
|
raw.replace("年", "-")
|
||||||
|
.replace("月", "-")
|
||||||
|
.replace("日", "")
|
||||||
|
.replace("/", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
)
|
||||||
|
formats = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M")
|
||||||
|
for time_format in formats:
|
||||||
|
try:
|
||||||
|
local = datetime.strptime(normalized, time_format).replace(
|
||||||
|
tzinfo=timezone(timedelta(hours=8))
|
||||||
|
)
|
||||||
|
ordered_at = local.astimezone(timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
).replace("+00:00", "Z")
|
||||||
|
return ordered_at, raw
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return "", raw
|
||||||
|
|
||||||
|
|
||||||
|
def parse_order_candidates(
|
||||||
|
xml_data: str | bytes, query: PurchaseReconcileQuery
|
||||||
|
) -> tuple[PurchaseOrderCandidate, ...]:
|
||||||
|
"""从脱敏控件树提取候选,只保留核单必需字段。"""
|
||||||
|
|
||||||
|
root = _parse_xml(xml_data)
|
||||||
|
parents = {
|
||||||
|
child: parent
|
||||||
|
for parent in root.iter("node")
|
||||||
|
for child in parent
|
||||||
|
if child.tag == "node"
|
||||||
|
}
|
||||||
|
parsed: dict[str, PurchaseOrderCandidate] = {}
|
||||||
|
for node in root.iter("node"):
|
||||||
|
node_label = _label(node)
|
||||||
|
order_match = _ORDER_NO_PATTERN.search(node_label)
|
||||||
|
if order_match is None:
|
||||||
|
continue
|
||||||
|
order_no = order_match.group(1)
|
||||||
|
current = node
|
||||||
|
card_text = node_label
|
||||||
|
for _ in range(8):
|
||||||
|
text = _subtree_text(current)
|
||||||
|
if any(marker in text for marker in _UNPAID_MARKERS):
|
||||||
|
card_text = text
|
||||||
|
if (
|
||||||
|
_TOTAL_PATTERN.search(text)
|
||||||
|
and _QUANTITY_PATTERN.search(text)
|
||||||
|
and _ORDER_TIME_PATTERN.search(text)
|
||||||
|
):
|
||||||
|
break
|
||||||
|
parent = parents.get(current)
|
||||||
|
if parent is None:
|
||||||
|
break
|
||||||
|
current = parent
|
||||||
|
|
||||||
|
goods_match = _GOODS_ID_PATTERN.search(card_text)
|
||||||
|
ordered_at, ordered_at_raw = _parse_ordered_at(card_text)
|
||||||
|
options = {
|
||||||
|
key: value
|
||||||
|
for key, value in query.options.items()
|
||||||
|
if str(value).strip() and str(value).strip() in card_text
|
||||||
|
}
|
||||||
|
payment_status = (
|
||||||
|
"unpaid"
|
||||||
|
if any(marker in card_text for marker in _UNPAID_MARKERS)
|
||||||
|
and not any(marker in card_text for marker in _NON_UNPAID_MARKERS)
|
||||||
|
else "other"
|
||||||
|
)
|
||||||
|
candidate = PurchaseOrderCandidate(
|
||||||
|
order_no=order_no,
|
||||||
|
goods_id=goods_match.group(1) if goods_match else "",
|
||||||
|
options=options,
|
||||||
|
quantity=_parse_quantity(card_text),
|
||||||
|
total_price_cent=_parse_money_cent(card_text),
|
||||||
|
ordered_at=ordered_at,
|
||||||
|
ordered_at_raw=ordered_at_raw,
|
||||||
|
payment_status=payment_status,
|
||||||
|
)
|
||||||
|
previous = parsed.get(order_no)
|
||||||
|
if previous is None or previous == candidate:
|
||||||
|
parsed[order_no] = candidate
|
||||||
|
return tuple(parsed.values())
|
||||||
|
|
||||||
|
|
||||||
|
class U2PddPurchaseReconcileAdapter(PddPurchaseReconcileAdapter):
|
||||||
|
"""只导航和滚动读取待付款订单,不包含任何订单写操作。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_address: str,
|
||||||
|
*,
|
||||||
|
device_service: Optional[PddDeviceService] = None,
|
||||||
|
cancelled: Callable[[], bool] = lambda: False,
|
||||||
|
settle_seconds: float = 0.4,
|
||||||
|
max_pages: int = 5,
|
||||||
|
) -> None:
|
||||||
|
self._device_address = str(device_address or "").strip()
|
||||||
|
self._device_service = device_service or _RECONCILE_DEVICE_SERVICE
|
||||||
|
self._cancelled = cancelled
|
||||||
|
self._settle_seconds = max(0.0, float(settle_seconds))
|
||||||
|
self._max_pages = max(1, int(max_pages))
|
||||||
|
self._session = None
|
||||||
|
self._device = None
|
||||||
|
|
||||||
|
def read_order_candidates(
|
||||||
|
self, query: PurchaseReconcileQuery
|
||||||
|
) -> PurchaseReconcileScan:
|
||||||
|
self._check_cancelled()
|
||||||
|
device = self._connect()
|
||||||
|
self._open_unpaid_orders(device)
|
||||||
|
found: dict[str, PurchaseOrderCandidate] = {}
|
||||||
|
previous_signature = ""
|
||||||
|
pages_scanned = 0
|
||||||
|
for _ in range(self._max_pages):
|
||||||
|
self._check_cancelled()
|
||||||
|
xml_data = device.dump_hierarchy(compressed=False)
|
||||||
|
self._raise_for_special_page(_parse_xml(xml_data))
|
||||||
|
pages_scanned += 1
|
||||||
|
for candidate in parse_order_candidates(xml_data, query):
|
||||||
|
found.setdefault(candidate.order_no, candidate)
|
||||||
|
signature = "|".join(sorted(found)) + f":{len(str(xml_data))}"
|
||||||
|
if signature == previous_signature:
|
||||||
|
break
|
||||||
|
previous_signature = signature
|
||||||
|
width, height = device.window_size()
|
||||||
|
device.swipe(
|
||||||
|
width // 2,
|
||||||
|
int(height * 0.78),
|
||||||
|
width // 2,
|
||||||
|
int(height * 0.32),
|
||||||
|
0.35,
|
||||||
|
)
|
||||||
|
self._settle()
|
||||||
|
return PurchaseReconcileScan(
|
||||||
|
candidates=tuple(found.values()),
|
||||||
|
diagnostics={
|
||||||
|
"pages_scanned": pages_scanned,
|
||||||
|
"candidate_count": len(found),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
session = self._session
|
||||||
|
self._session = None
|
||||||
|
self._device = None
|
||||||
|
if session is not None:
|
||||||
|
session.__exit__(None, None, None)
|
||||||
|
|
||||||
|
def _connect(self) -> Any:
|
||||||
|
if self._device is None:
|
||||||
|
self._session = self._device_service.connect(self._device_address)
|
||||||
|
self._device = self._session.__enter__()
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
def _open_unpaid_orders(self, device: Any) -> None:
|
||||||
|
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
|
||||||
|
for _ in range(6):
|
||||||
|
self._check_cancelled()
|
||||||
|
current = device.app_current()
|
||||||
|
if str(current.get("package") or "") != PDD_PACKAGE_NAME:
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
"RECONCILE_WRONG_APP", "核单时 PDD 不在前台"
|
||||||
|
)
|
||||||
|
root = _parse_xml(device.dump_hierarchy(compressed=False))
|
||||||
|
self._raise_for_special_page(root)
|
||||||
|
combined = _subtree_text(root)
|
||||||
|
if opened_order_area and (
|
||||||
|
_ORDER_NO_PATTERN.search(combined)
|
||||||
|
or "暂无订单" in combined
|
||||||
|
or "暂无相关订单" in combined
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if any(marker in combined for marker in _PAYMENT_ACTION_MARKERS):
|
||||||
|
device.press("back")
|
||||||
|
self._settle()
|
||||||
|
continue
|
||||||
|
if "我的订单" in combined:
|
||||||
|
if self._click_unique_label(device, root, "待付款"):
|
||||||
|
opened_order_area = True
|
||||||
|
self._settle()
|
||||||
|
continue
|
||||||
|
if self._click_unique_label(device, root, "我的订单"):
|
||||||
|
opened_order_area = True
|
||||||
|
self._settle()
|
||||||
|
continue
|
||||||
|
if self._click_unique_label(device, root, "个人中心"):
|
||||||
|
self._settle()
|
||||||
|
continue
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
"RECONCILE_ORDER_ENTRY_NOT_FOUND",
|
||||||
|
"没有找到唯一的“个人中心/我的订单/待付款”只读入口",
|
||||||
|
)
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
"RECONCILE_ORDER_PAGE_TIMEOUT", "打开待付款订单列表超时"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _click_unique_label(
|
||||||
|
device: Any, root: ET.Element, target: str
|
||||||
|
) -> bool:
|
||||||
|
if target not in _SAFE_NAVIGATION_LABELS:
|
||||||
|
raise ValueError("只允许使用白名单只读导航入口")
|
||||||
|
targets = []
|
||||||
|
for node in root.iter("node"):
|
||||||
|
labels = {
|
||||||
|
node.get("text", "").strip(),
|
||||||
|
node.get("content-desc", "").strip(),
|
||||||
|
}
|
||||||
|
if target not in labels:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
node.get("visible-to-user") != "true"
|
||||||
|
or node.get("enabled") != "true"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
bounds = _parse_bounds(node.get("bounds", ""))
|
||||||
|
if bounds is not None:
|
||||||
|
targets.append(bounds)
|
||||||
|
unique = list(dict.fromkeys(targets))
|
||||||
|
if len(unique) != 1:
|
||||||
|
return False
|
||||||
|
left, top, right, bottom = unique[0]
|
||||||
|
device.click((left + right) // 2, (top + bottom) // 2)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_for_special_page(root: ET.Element) -> None:
|
||||||
|
combined = _subtree_text(root)
|
||||||
|
markers = (
|
||||||
|
("RECONCILE_CAPTCHA", _CAPTCHA_MARKERS),
|
||||||
|
("RECONCILE_LOGIN_REQUIRED", _LOGIN_MARKERS),
|
||||||
|
("RECONCILE_RISK_CONTROL", _RISK_MARKERS),
|
||||||
|
)
|
||||||
|
for code, values in markers:
|
||||||
|
if any(value in combined for value in values):
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
code, "PDD 核单遇到登录、验证或风控页面,请人工处理"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_cancelled(self) -> None:
|
||||||
|
if self._cancelled():
|
||||||
|
raise PddPurchaseReconcileError(
|
||||||
|
"RECONCILE_CANCELLED", "用户已停止只读订单核对"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _settle(self) -> None:
|
||||||
|
if self._settle_seconds:
|
||||||
|
time.sleep(self._settle_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
_RECONCILE_DEVICE_SERVICE = PddDeviceService()
|
||||||
|
|
||||||
|
|
||||||
|
def create_u2_purchase_reconcile_adapter(
|
||||||
|
device_address: str, cancelled: Callable[[], bool]
|
||||||
|
) -> PddPurchaseReconcileAdapter:
|
||||||
|
"""为正式 Client 创建只读订单核对会话。"""
|
||||||
|
|
||||||
|
return U2PddPurchaseReconcileAdapter(
|
||||||
|
device_address,
|
||||||
|
device_service=_RECONCILE_DEVICE_SERVICE,
|
||||||
|
cancelled=cancelled,
|
||||||
|
)
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"""不可逆阶段中断后的只读采购结果核对。"""
|
"""不可逆阶段后的只读采购订单核对与结果入队。"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Mapping
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable, Mapping, Optional
|
||||||
|
|
||||||
from .pdd_purchase_reconcile_adapter import (
|
from .pdd_purchase_reconcile_adapter import (
|
||||||
PddPurchaseReconcileAdapter,
|
PddPurchaseReconcileAdapter,
|
||||||
PurchaseReconcileObservation,
|
PurchaseOrderCandidate,
|
||||||
PurchaseReconcileQuery,
|
PurchaseReconcileQuery,
|
||||||
|
PurchaseReconcileScan,
|
||||||
)
|
)
|
||||||
from .task_models import TaskDetail
|
from .task_models import TaskDetail, TaskRunRecord
|
||||||
from .task_repository import TaskRepository
|
from .task_repository import TaskRepository
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,22 @@ PurchaseReconcileFactory = Callable[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_time(value: str) -> datetime:
|
||||||
|
checked = str(value or "").strip()
|
||||||
|
if checked.endswith("Z"):
|
||||||
|
checked = checked[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(checked)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("订单时间缺少时区")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PurchaseReconcileOutcome:
|
class PurchaseReconcileOutcome:
|
||||||
"""只读核对的简短结果。"""
|
"""只读核对的简短结果。"""
|
||||||
@@ -27,7 +45,11 @@ class PurchaseReconcileOutcome:
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseReconcileService:
|
class PurchaseReconcileService:
|
||||||
"""只读核对一条已进入不可逆阶段的采购运行。"""
|
"""严格匹配未付款订单;唯一匹配才生成采购成功 Outbox。"""
|
||||||
|
|
||||||
|
_DIAGNOSTIC_KEYS = frozenset(
|
||||||
|
{"pages_scanned", "candidate_count", "error_code", "error_message"}
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -45,7 +67,7 @@ class PurchaseReconcileService:
|
|||||||
def execute_selected(
|
def execute_selected(
|
||||||
self, remote_task_id: str
|
self, remote_task_id: str
|
||||||
) -> PurchaseReconcileOutcome:
|
) -> PurchaseReconcileOutcome:
|
||||||
"""执行一次只读核对,任何结果都交给人工最终确认。"""
|
"""只读核对一次;唯一未付款订单入 Outbox,其余转人工。"""
|
||||||
|
|
||||||
if not self._device_address:
|
if not self._device_address:
|
||||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||||
@@ -60,17 +82,82 @@ class PurchaseReconcileService:
|
|||||||
if run.irreversible_action_at is None:
|
if run.irreversible_action_at is None:
|
||||||
raise ValueError("未进入不可逆阶段,不应启动订单核对")
|
raise ValueError("未进入不可逆阶段,不应启动订单核对")
|
||||||
|
|
||||||
query = self._query(task, run.irreversible_action_at)
|
try:
|
||||||
adapter = None
|
query = self._query(task, run)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._save_manual_review(
|
||||||
|
task,
|
||||||
|
run,
|
||||||
|
"unknown",
|
||||||
|
{"error_code": "RECONCILE_QUERY_INVALID", "error_message": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
scan, close_error = self._read_candidates(query)
|
||||||
|
candidates = tuple(scan.candidates)
|
||||||
|
matching = tuple(
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if self._candidate_matches(candidate, query)
|
||||||
|
)
|
||||||
|
diagnostics = {
|
||||||
|
key: value
|
||||||
|
for key, value in scan.diagnostics.items()
|
||||||
|
if key in self._DIAGNOSTIC_KEYS
|
||||||
|
}
|
||||||
|
diagnostics.update(
|
||||||
|
{
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"matching_candidate_count": len(matching),
|
||||||
|
"mode": "reconcile_only",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if close_error:
|
||||||
|
diagnostics["close_error"] = close_error[:300]
|
||||||
|
|
||||||
|
if len(matching) == 1:
|
||||||
|
candidate = matching[0]
|
||||||
|
result = self._result_data(task, query, candidate)
|
||||||
|
event = self._repository.save_matched_purchase_reconciliation(
|
||||||
|
task.remote_task_id,
|
||||||
|
run.attempt_id,
|
||||||
|
result,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
return PurchaseReconcileOutcome(
|
||||||
|
"result_pending",
|
||||||
|
f"任务 {task.remote_task_id} 已核对到唯一未付款订单,等待提交 Admin",
|
||||||
|
task.remote_task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(matching) > 1:
|
||||||
|
return self._save_manual_review(
|
||||||
|
task, run, "ambiguous", diagnostics
|
||||||
|
)
|
||||||
|
match_status = "not_found" if not candidates else "unknown"
|
||||||
|
return self._save_manual_review(task, run, match_status, diagnostics)
|
||||||
|
|
||||||
|
def _read_candidates(
|
||||||
|
self, query: PurchaseReconcileQuery
|
||||||
|
) -> tuple[PurchaseReconcileScan, str]:
|
||||||
|
adapter: Optional[PddPurchaseReconcileAdapter] = None
|
||||||
close_error = ""
|
close_error = ""
|
||||||
|
scan = PurchaseReconcileScan()
|
||||||
try:
|
try:
|
||||||
adapter = self._factory(self._device_address, self._cancelled)
|
adapter = self._factory(self._device_address, self._cancelled)
|
||||||
observation = adapter.read_order_match(query)
|
scan = adapter.read_order_candidates(query)
|
||||||
if not isinstance(observation, PurchaseReconcileObservation):
|
if not isinstance(scan, PurchaseReconcileScan):
|
||||||
raise TypeError("采购核对 Adapter 返回值无效")
|
raise TypeError("采购核对 Adapter 返回值无效")
|
||||||
|
if any(
|
||||||
|
not isinstance(candidate, PurchaseOrderCandidate)
|
||||||
|
for candidate in scan.candidates
|
||||||
|
):
|
||||||
|
raise TypeError("采购核对候选数据无效")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
observation = PurchaseReconcileObservation(
|
scan = PurchaseReconcileScan(
|
||||||
"unknown", diagnostics={"error": str(exc)}
|
diagnostics={
|
||||||
|
"error_code": "RECONCILE_READ_FAILED",
|
||||||
|
"error_message": str(exc)[:300],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
@@ -78,43 +165,63 @@ class PurchaseReconcileService:
|
|||||||
adapter.close()
|
adapter.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
close_error = str(exc)
|
close_error = str(exc)
|
||||||
|
return scan, close_error
|
||||||
|
|
||||||
diagnostics = dict(observation.diagnostics)
|
def _save_manual_review(
|
||||||
diagnostics.update(
|
self,
|
||||||
{
|
task: TaskDetail,
|
||||||
"order_no": observation.order_no,
|
run: TaskRunRecord,
|
||||||
"ordered_at": observation.ordered_at,
|
match_status: str,
|
||||||
"mode": "reconcile_only",
|
diagnostics: Mapping[str, object],
|
||||||
}
|
) -> PurchaseReconcileOutcome:
|
||||||
)
|
|
||||||
if close_error:
|
|
||||||
diagnostics["close_error"] = close_error
|
|
||||||
self._repository.save_purchase_reconciliation(
|
self._repository.save_purchase_reconciliation(
|
||||||
remote_task_id,
|
task.remote_task_id,
|
||||||
run.attempt_id,
|
run.attempt_id,
|
||||||
observation.match_status,
|
match_status,
|
||||||
diagnostics,
|
dict(diagnostics),
|
||||||
)
|
)
|
||||||
if observation.match_status == "matched":
|
messages = {
|
||||||
message = (
|
"not_found": "未找到符合时间范围的订单",
|
||||||
f"任务 {remote_task_id} 仅核对到唯一候选订单;"
|
"ambiguous": "找到多个完全匹配的未付款订单",
|
||||||
"请人工确认,程序不会重新下单"
|
"unknown": "订单字段不完整、不一致或读取失败",
|
||||||
)
|
}
|
||||||
else:
|
|
||||||
message = (
|
|
||||||
f"任务 {remote_task_id} 核对结果不确定;"
|
|
||||||
"需人工处理,程序不会重新下单"
|
|
||||||
)
|
|
||||||
return PurchaseReconcileOutcome(
|
return PurchaseReconcileOutcome(
|
||||||
"manual_review", message, remote_task_id
|
"manual_review",
|
||||||
|
f"任务 {task.remote_task_id} {messages[match_status]};需人工处理,绝不重新下单",
|
||||||
|
task.remote_task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate_matches(
|
||||||
|
candidate: PurchaseOrderCandidate,
|
||||||
|
query: PurchaseReconcileQuery,
|
||||||
|
) -> bool:
|
||||||
|
if (
|
||||||
|
not candidate.order_no.strip()
|
||||||
|
or candidate.goods_id.strip() != query.goods_id
|
||||||
|
or dict(candidate.options) != dict(query.options)
|
||||||
|
or candidate.quantity != query.quantity
|
||||||
|
or candidate.total_price_cent != query.total_price_cent
|
||||||
|
or candidate.payment_status != "unpaid"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ordered_at = _parse_iso_time(candidate.ordered_at)
|
||||||
|
lower = _parse_iso_time(query.irreversible_action_at) - timedelta(
|
||||||
|
minutes=5
|
||||||
|
)
|
||||||
|
upper = _parse_iso_time(query.reconcile_started_at) + timedelta(
|
||||||
|
minutes=5
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return lower <= ordered_at <= upper
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _query(
|
def _query(
|
||||||
task: TaskDetail, irreversible_action_at: str
|
task: TaskDetail, run: TaskRunRecord
|
||||||
) -> PurchaseReconcileQuery:
|
) -> PurchaseReconcileQuery:
|
||||||
payload_root = task.admin_payload
|
payload = task.admin_payload.get("payload")
|
||||||
payload = payload_root.get("payload")
|
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
raise ValueError("采购任务缺少 payload")
|
raise ValueError("采购任务缺少 payload")
|
||||||
options = payload.get("options")
|
options = payload.get("options")
|
||||||
@@ -122,6 +229,11 @@ class PurchaseReconcileService:
|
|||||||
raise ValueError("采购任务缺少 options")
|
raise ValueError("采购任务缺少 options")
|
||||||
goods_id = str(payload.get("goods_id") or "").strip()
|
goods_id = str(payload.get("goods_id") or "").strip()
|
||||||
quantity = payload.get("quantity")
|
quantity = payload.get("quantity")
|
||||||
|
snapshot = run.diagnostics_json.get("final_confirmation")
|
||||||
|
if not isinstance(snapshot, Mapping):
|
||||||
|
raise ValueError("采购运行缺少提交前确认快照")
|
||||||
|
total_price_cent = snapshot.get("total_price_cent")
|
||||||
|
unit_price_cent = snapshot.get("unit_price_cent")
|
||||||
if not goods_id:
|
if not goods_id:
|
||||||
raise ValueError("采购任务缺少 goods_id")
|
raise ValueError("采购任务缺少 goods_id")
|
||||||
if (
|
if (
|
||||||
@@ -130,9 +242,63 @@ class PurchaseReconcileService:
|
|||||||
or quantity <= 0
|
or quantity <= 0
|
||||||
):
|
):
|
||||||
raise ValueError("采购任务缺少有效 quantity")
|
raise ValueError("采购任务缺少有效 quantity")
|
||||||
|
if (
|
||||||
|
isinstance(total_price_cent, bool)
|
||||||
|
or not isinstance(total_price_cent, int)
|
||||||
|
or total_price_cent <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("采购运行缺少有效确认总价")
|
||||||
|
if (
|
||||||
|
isinstance(unit_price_cent, bool)
|
||||||
|
or not isinstance(unit_price_cent, int)
|
||||||
|
or unit_price_cent <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("采购运行缺少有效确认单价")
|
||||||
return PurchaseReconcileQuery(
|
return PurchaseReconcileQuery(
|
||||||
goods_id=goods_id,
|
goods_id=goods_id,
|
||||||
options={str(k): str(v) for k, v in options.items()},
|
options={str(key): str(value) for key, value in options.items()},
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
irreversible_action_at=irreversible_action_at,
|
unit_price_cent=unit_price_cent,
|
||||||
|
total_price_cent=total_price_cent,
|
||||||
|
irreversible_action_at=str(run.irreversible_action_at),
|
||||||
|
reconcile_started_at=_utc_now_iso(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _result_data(
|
||||||
|
self,
|
||||||
|
task: TaskDetail,
|
||||||
|
query: PurchaseReconcileQuery,
|
||||||
|
candidate: PurchaseOrderCandidate,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"goods_id": query.goods_id,
|
||||||
|
"goods_url": task.goods_url,
|
||||||
|
"purchase": {
|
||||||
|
"mode": "live",
|
||||||
|
"requested": {
|
||||||
|
"options": dict(query.options),
|
||||||
|
"quantity": query.quantity,
|
||||||
|
"max_price_cent": task.price_cent,
|
||||||
|
},
|
||||||
|
"confirmed": {
|
||||||
|
"options": dict(candidate.options),
|
||||||
|
"quantity": candidate.quantity,
|
||||||
|
"unit_price_cent": query.unit_price_cent,
|
||||||
|
"total_price_cent": candidate.total_price_cent,
|
||||||
|
},
|
||||||
|
"confirmation_reached": True,
|
||||||
|
"order_submitted": True,
|
||||||
|
"payment_attempted": False,
|
||||||
|
"payment_status": "unpaid",
|
||||||
|
"order_no": candidate.order_no,
|
||||||
|
"ordered_at": candidate.ordered_at,
|
||||||
|
"ordered_at_raw": candidate.ordered_at_raw,
|
||||||
|
"match_status": "matched",
|
||||||
|
},
|
||||||
|
"captured_at": _utc_now_iso(),
|
||||||
|
"source": {
|
||||||
|
"device_address": self._device_address,
|
||||||
|
"mode": "reconcile_only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,7 +225,14 @@ class PurchaseTaskService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
irreversible_at = self._repository.mark_purchase_irreversible(
|
irreversible_at = self._repository.mark_purchase_irreversible(
|
||||||
remote_task_id, attempt_id
|
remote_task_id,
|
||||||
|
attempt_id,
|
||||||
|
{
|
||||||
|
"options": dict(state.selected_options),
|
||||||
|
"quantity": state.quantity,
|
||||||
|
"unit_price_cent": state.price_cent,
|
||||||
|
"total_price_cent": state.price_cent * state.quantity,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
submitted_at = None
|
submitted_at = None
|
||||||
message = "订单提交点击已执行,结果待只读核对;绝不重新下单"
|
message = "订单提交点击已执行,结果待只读核对;绝不重新下单"
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ CURRENT_STEP_TEXT = {
|
|||||||
"purchase_submit_once": "已单击一次提交订单",
|
"purchase_submit_once": "已单击一次提交订单",
|
||||||
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
||||||
"purchase_recovery_ready": "上次演练中断,已安全等待恢复",
|
"purchase_recovery_ready": "上次演练中断,已安全等待恢复",
|
||||||
"reconcile_purchase": "只允许核对订单",
|
"reconcile_purchase": "订单已提交,只允许核对未付款订单",
|
||||||
"reconcile_completed": "只读核对完成,等待人工确认",
|
"purchase_order_matched_pending_report": "已提交待付款,等待向 Admin 上报",
|
||||||
"reconcile_manual_review": "核对结果不确定,需人工处理",
|
"reconcile_completed": "已核对到订单,等待人工确认",
|
||||||
|
"reconcile_manual_review": "订单结果不确定或存在多个候选,需人工处理",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+172
-10
@@ -602,12 +602,19 @@ class TaskRepository:
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
def mark_purchase_irreversible(
|
def mark_purchase_irreversible(
|
||||||
self, remote_task_id: str, attempt_id: str
|
self,
|
||||||
|
remote_task_id: str,
|
||||||
|
attempt_id: str,
|
||||||
|
final_confirmation: Optional[Dict[str, object]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""事务写入不可逆时间;成功返回后才允许点击提交订单。"""
|
"""同一事务保存最终确认快照和不可逆时间,再允许提交订单。"""
|
||||||
|
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
step = "purchase_irreversible_step_entered"
|
step = "purchase_irreversible_step_entered"
|
||||||
|
diagnostics_json = json.dumps(
|
||||||
|
{"final_confirmation": dict(final_confirmation or {})},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
connection = open_database(self._db_path)
|
connection = open_database(self._db_path)
|
||||||
try:
|
try:
|
||||||
with connection:
|
with connection:
|
||||||
@@ -626,11 +633,18 @@ class TaskRepository:
|
|||||||
raise ValueError("采购任务当前不在执行中")
|
raise ValueError("采购任务当前不在执行中")
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"UPDATE task_runs SET irreversible_action_at = ?,"
|
"UPDATE task_runs SET irreversible_action_at = ?,"
|
||||||
" current_step = ?, updated_at = ?"
|
" current_step = ?, diagnostics_json = ?, updated_at = ?"
|
||||||
" WHERE task_id = ? AND attempt_id = ?"
|
" WHERE task_id = ? AND attempt_id = ?"
|
||||||
" AND run_status = 'running'"
|
" AND run_status = 'running'"
|
||||||
" AND irreversible_action_at IS NULL",
|
" AND irreversible_action_at IS NULL",
|
||||||
(now, step, now, task["id"], attempt_id),
|
(
|
||||||
|
now,
|
||||||
|
step,
|
||||||
|
diagnostics_json,
|
||||||
|
now,
|
||||||
|
task["id"],
|
||||||
|
attempt_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise ValueError("不可逆标记写入失败或已经存在")
|
raise ValueError("不可逆标记写入失败或已经存在")
|
||||||
@@ -719,9 +733,16 @@ class TaskRepository:
|
|||||||
"matched": "只读核对发现唯一候选订单,请人工确认",
|
"matched": "只读核对发现唯一候选订单,请人工确认",
|
||||||
"not_found": "只读核对未找到订单,不得重新下单",
|
"not_found": "只读核对未找到订单,不得重新下单",
|
||||||
"ambiguous": "只读核对发现多个候选订单,请人工确认",
|
"ambiguous": "只读核对发现多个候选订单,请人工确认",
|
||||||
"unknown": "无法确定采购结果,不得重新下单",
|
"unknown": "订单字段不完整、不一致或读取失败,不得重新下单",
|
||||||
|
}
|
||||||
|
error_codes = {
|
||||||
|
"matched": "PURCHASE_RECONCILED",
|
||||||
|
"not_found": "ORDER_NOT_FOUND",
|
||||||
|
"ambiguous": "AMBIGUOUS_ORDER_MATCH",
|
||||||
|
"unknown": "ORDER_MATCH_UNCERTAIN",
|
||||||
}
|
}
|
||||||
message = messages[match_status]
|
message = messages[match_status]
|
||||||
|
error_code = error_codes[match_status]
|
||||||
saved_diagnostics = dict(diagnostics)
|
saved_diagnostics = dict(diagnostics)
|
||||||
saved_diagnostics["match_status"] = match_status
|
saved_diagnostics["match_status"] = match_status
|
||||||
connection = open_database(self._db_path)
|
connection = open_database(self._db_path)
|
||||||
@@ -742,7 +763,8 @@ class TaskRepository:
|
|||||||
):
|
):
|
||||||
raise ValueError("采购任务当前不在待核对状态")
|
raise ValueError("采购任务当前不在待核对状态")
|
||||||
run = connection.execute(
|
run = connection.execute(
|
||||||
"SELECT irreversible_action_at, run_status, current_step"
|
"SELECT irreversible_action_at, run_status, current_step,"
|
||||||
|
" diagnostics_json"
|
||||||
" FROM task_runs"
|
" FROM task_runs"
|
||||||
" WHERE task_id = ? AND attempt_id = ?",
|
" WHERE task_id = ? AND attempt_id = ?",
|
||||||
(task["id"], attempt_id),
|
(task["id"], attempt_id),
|
||||||
@@ -754,15 +776,22 @@ class TaskRepository:
|
|||||||
or run["current_step"] != "reconcile_purchase"
|
or run["current_step"] != "reconcile_purchase"
|
||||||
):
|
):
|
||||||
raise ValueError("采购执行记录已经核对或状态已变更")
|
raise ValueError("采购执行记录已经核对或状态已变更")
|
||||||
|
run_diagnostics = (
|
||||||
|
self._load_json_object(run["diagnostics_json"])
|
||||||
|
if run["diagnostics_json"]
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
run_diagnostics["reconciliation"] = saved_diagnostics
|
||||||
run_cursor = connection.execute(
|
run_cursor = connection.execute(
|
||||||
"UPDATE task_runs SET run_status = 'manual_review',"
|
"UPDATE task_runs SET run_status = 'manual_review',"
|
||||||
" current_step = ?, error_code = 'PURCHASE_RECONCILED',"
|
" current_step = ?, error_code = ?,"
|
||||||
" error_message = ?, diagnostics_json = ?, updated_at = ?"
|
" error_message = ?, diagnostics_json = ?, updated_at = ?"
|
||||||
" WHERE task_id = ? AND attempt_id = ?",
|
" WHERE task_id = ? AND attempt_id = ?",
|
||||||
(
|
(
|
||||||
step,
|
step,
|
||||||
|
error_code,
|
||||||
message,
|
message,
|
||||||
json.dumps(saved_diagnostics, ensure_ascii=False),
|
json.dumps(run_diagnostics, ensure_ascii=False),
|
||||||
now,
|
now,
|
||||||
task["id"],
|
task["id"],
|
||||||
attempt_id,
|
attempt_id,
|
||||||
@@ -772,17 +801,150 @@ class TaskRepository:
|
|||||||
raise ValueError("采购执行记录核对保存失败")
|
raise ValueError("采购执行记录核对保存失败")
|
||||||
task_cursor = connection.execute(
|
task_cursor = connection.execute(
|
||||||
"UPDATE pdd_tasks SET status = 'manual_review',"
|
"UPDATE pdd_tasks SET status = 'manual_review',"
|
||||||
" current_step = ?, last_error_code = 'PURCHASE_RECONCILED',"
|
" current_step = ?, last_error_code = ?,"
|
||||||
" last_error_message = ?, updated_at = ? WHERE id = ?"
|
" last_error_message = ?, updated_at = ? WHERE id = ?"
|
||||||
" AND status = 'manual_review'"
|
" AND status = 'manual_review'"
|
||||||
" AND current_step = 'reconcile_purchase'",
|
" AND current_step = 'reconcile_purchase'",
|
||||||
(step, message, now, task["id"]),
|
(step, error_code, message, now, task["id"]),
|
||||||
)
|
)
|
||||||
if task_cursor.rowcount != 1:
|
if task_cursor.rowcount != 1:
|
||||||
raise ValueError("采购任务状态已变更,核对结果未保存")
|
raise ValueError("采购任务状态已变更,核对结果未保存")
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
def save_matched_purchase_reconciliation(
|
||||||
|
self,
|
||||||
|
remote_task_id: str,
|
||||||
|
attempt_id: str,
|
||||||
|
pdd_data: Dict[str, object],
|
||||||
|
diagnostics: Dict[str, object],
|
||||||
|
) -> OutboxEventRecord:
|
||||||
|
"""唯一未付款订单核对成功后,原子保存结果并创建幂等 Outbox。"""
|
||||||
|
|
||||||
|
purchase = pdd_data.get("purchase")
|
||||||
|
if not isinstance(purchase, dict) or (
|
||||||
|
purchase.get("mode") != "live"
|
||||||
|
or purchase.get("match_status") != "matched"
|
||||||
|
or purchase.get("order_submitted") is not True
|
||||||
|
or purchase.get("payment_attempted") is not False
|
||||||
|
or purchase.get("payment_status") != "unpaid"
|
||||||
|
or not str(purchase.get("order_no") or "").strip()
|
||||||
|
):
|
||||||
|
raise ValueError("真实采购核对结果结构无效")
|
||||||
|
|
||||||
|
now = utc_now_iso()
|
||||||
|
idempotency_key = f"{remote_task_id}:{attempt_id}:result-v1"
|
||||||
|
connection = open_database(self._db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT id FROM outbox_events WHERE idempotency_key = ?",
|
||||||
|
(idempotency_key,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
event_id = int(existing["id"])
|
||||||
|
else:
|
||||||
|
task = connection.execute(
|
||||||
|
"SELECT id, version, task_type, status, current_step"
|
||||||
|
" FROM pdd_tasks WHERE remote_task_id = ?",
|
||||||
|
(remote_task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise ValueError(f"任务 {remote_task_id} 不存在")
|
||||||
|
if task["task_type"] != TaskType.PURCHASE.value:
|
||||||
|
raise ValueError("当前任务不是采购任务")
|
||||||
|
if (
|
||||||
|
task["status"] != TaskStatus.MANUAL_REVIEW.value
|
||||||
|
or task["current_step"] != "reconcile_purchase"
|
||||||
|
):
|
||||||
|
raise ValueError("采购任务当前不在待核对状态")
|
||||||
|
run = connection.execute(
|
||||||
|
"SELECT run_status, current_step, diagnostics_json,"
|
||||||
|
" irreversible_action_at FROM task_runs"
|
||||||
|
" WHERE task_id = ? AND attempt_id = ?",
|
||||||
|
(task["id"], attempt_id),
|
||||||
|
).fetchone()
|
||||||
|
if (
|
||||||
|
run is None
|
||||||
|
or run["irreversible_action_at"] is None
|
||||||
|
or run["run_status"] != RunStatus.MANUAL_REVIEW.value
|
||||||
|
or run["current_step"] != "reconcile_purchase"
|
||||||
|
):
|
||||||
|
raise ValueError("不可逆采购执行记录不在待核对状态")
|
||||||
|
|
||||||
|
run_diagnostics = (
|
||||||
|
self._load_json_object(run["diagnostics_json"])
|
||||||
|
if run["diagnostics_json"]
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
run_diagnostics["reconciliation"] = dict(diagnostics)
|
||||||
|
result_json = json.dumps(pdd_data, ensure_ascii=False)
|
||||||
|
payload = {
|
||||||
|
"task_version": task["version"],
|
||||||
|
"attempt_id": attempt_id,
|
||||||
|
"result_type": "purchase",
|
||||||
|
"completed_at": now,
|
||||||
|
"pdd_data": pdd_data,
|
||||||
|
}
|
||||||
|
task_cursor = connection.execute(
|
||||||
|
"UPDATE pdd_tasks SET status = 'result_pending',"
|
||||||
|
" current_step = 'purchase_order_matched_pending_report',"
|
||||||
|
" pdd_data = ?,"
|
||||||
|
" price_cent = ?, last_error_code = NULL,"
|
||||||
|
" last_error_message = NULL, finished_at = ?, updated_at = ?"
|
||||||
|
" WHERE id = ? AND status = 'manual_review'"
|
||||||
|
" AND current_step = 'reconcile_purchase'",
|
||||||
|
(
|
||||||
|
result_json,
|
||||||
|
self._purchase_result_price(pdd_data),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
task["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if task_cursor.rowcount != 1:
|
||||||
|
raise ValueError("采购任务状态已变化,未保存核对结果")
|
||||||
|
run_cursor = connection.execute(
|
||||||
|
"UPDATE task_runs SET run_status = 'succeeded',"
|
||||||
|
" current_step = 'purchase_order_matched_pending_report',"
|
||||||
|
" result_data = ?,"
|
||||||
|
" diagnostics_json = ?, error_code = NULL,"
|
||||||
|
" error_message = NULL, finished_at = ?, updated_at = ?"
|
||||||
|
" WHERE task_id = ? AND attempt_id = ?"
|
||||||
|
" AND run_status = 'manual_review'"
|
||||||
|
" AND current_step = 'reconcile_purchase'"
|
||||||
|
" AND irreversible_action_at IS NOT NULL",
|
||||||
|
(
|
||||||
|
result_json,
|
||||||
|
json.dumps(run_diagnostics, ensure_ascii=False),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
task["id"],
|
||||||
|
attempt_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if run_cursor.rowcount != 1:
|
||||||
|
raise ValueError("采购执行记录状态已变化,未保存核对结果")
|
||||||
|
event_cursor = connection.execute(
|
||||||
|
"INSERT INTO outbox_events (task_id, event_type,"
|
||||||
|
" idempotency_key, payload_json, status, created_at,"
|
||||||
|
" updated_at)"
|
||||||
|
" VALUES (?, 'purchase_result', ?, ?, 'pending', ?, ?)",
|
||||||
|
(
|
||||||
|
task["id"],
|
||||||
|
idempotency_key,
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event_id = int(event_cursor.lastrowid)
|
||||||
|
event = self.get_outbox_event(event_id)
|
||||||
|
assert event is not None
|
||||||
|
return event
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
def save_purchase_result(
|
def save_purchase_result(
|
||||||
self,
|
self,
|
||||||
remote_task_id: str,
|
remote_task_id: str,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ from .pdd_u2_purchase_adapter import (
|
|||||||
create_u2_live_purchase_adapter,
|
create_u2_live_purchase_adapter,
|
||||||
create_u2_purchase_adapter,
|
create_u2_purchase_adapter,
|
||||||
)
|
)
|
||||||
|
from .pdd_u2_purchase_reconcile_adapter import (
|
||||||
|
create_u2_purchase_reconcile_adapter,
|
||||||
|
)
|
||||||
from .settings_ui import SettingsPage
|
from .settings_ui import SettingsPage
|
||||||
from .task_repository import TaskRepository
|
from .task_repository import TaskRepository
|
||||||
from .update_service import mark_current_version_healthy
|
from .update_service import mark_current_version_healthy
|
||||||
@@ -62,6 +65,7 @@ class MainWindow(FluentWindow):
|
|||||||
self,
|
self,
|
||||||
purchase_adapter_factory=create_u2_purchase_adapter,
|
purchase_adapter_factory=create_u2_purchase_adapter,
|
||||||
live_purchase_adapter_factory=create_u2_live_purchase_adapter,
|
live_purchase_adapter_factory=create_u2_live_purchase_adapter,
|
||||||
|
purchase_reconcile_factory=create_u2_purchase_reconcile_adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.pddTaskPage.openSettingsRequested.connect(
|
self.pddTaskPage.openSettingsRequested.connect(
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""只读订单核对 XML 解析测试;不连接真实手机。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.pdd_purchase_reconcile_adapter import PurchaseReconcileQuery
|
||||||
|
from src.pdd_u2_purchase_reconcile_adapter import parse_order_candidates
|
||||||
|
|
||||||
|
|
||||||
|
def query() -> PurchaseReconcileQuery:
|
||||||
|
return PurchaseReconcileQuery(
|
||||||
|
goods_id="737116531267",
|
||||||
|
options={"color": "黑色", "size": "L"},
|
||||||
|
quantity=2,
|
||||||
|
unit_price_cent=4200,
|
||||||
|
total_price_cent=8400,
|
||||||
|
irreversible_action_at="2026-08-10T08:00:00Z",
|
||||||
|
reconcile_started_at="2026-08-10T08:05:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def xml_card(text: str) -> str:
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<hierarchy rotation="0">'
|
||||||
|
f'<node text="" content-desc="{text}" bounds="[0,100][1080,900]" '
|
||||||
|
'visible-to-user="true" enabled="true" />'
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
|
||||||
|
def test_parser_extracts_only_required_unpaid_order_fields(self):
|
||||||
|
candidates = parse_order_candidates(
|
||||||
|
xml_card(
|
||||||
|
"待付款 订单编号:ORDER-20260810 商品编号:737116531267 "
|
||||||
|
"黑色 L 共2件 合计 ¥84.00 下单时间:2026-08-10 16:03:00"
|
||||||
|
),
|
||||||
|
query(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(candidates), 1)
|
||||||
|
candidate = candidates[0]
|
||||||
|
self.assertEqual(candidate.order_no, "ORDER-20260810")
|
||||||
|
self.assertEqual(candidate.goods_id, "737116531267")
|
||||||
|
self.assertEqual(candidate.options, {"color": "黑色", "size": "L"})
|
||||||
|
self.assertEqual(candidate.quantity, 2)
|
||||||
|
self.assertEqual(candidate.total_price_cent, 8400)
|
||||||
|
self.assertEqual(candidate.ordered_at, "2026-08-10T08:03:00Z")
|
||||||
|
self.assertEqual(candidate.payment_status, "unpaid")
|
||||||
|
self.assertFalse(hasattr(candidate, "recipient"))
|
||||||
|
self.assertFalse(hasattr(candidate, "address"))
|
||||||
|
|
||||||
|
def test_paid_or_cancelled_text_never_becomes_unpaid(self):
|
||||||
|
candidates = parse_order_candidates(
|
||||||
|
xml_card(
|
||||||
|
"待付款 已付款 订单编号:ORDER-PAID 商品编号:737116531267 "
|
||||||
|
"黑色 L 共2件 合计 ¥84.00 下单时间:2026-08-10 16:03:00"
|
||||||
|
),
|
||||||
|
query(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(candidates[0].payment_status, "other")
|
||||||
|
|
||||||
|
def test_missing_goods_id_and_time_are_kept_incomplete_for_manual_review(self):
|
||||||
|
candidates = parse_order_candidates(
|
||||||
|
xml_card("待付款 订单编号:ORDER-INCOMPLETE 黑色 L 共2件 合计 ¥84.00"),
|
||||||
|
query(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(candidates[0].goods_id, "")
|
||||||
|
self.assertEqual(candidates[0].ordered_at, "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
||||||
from src.db import open_database
|
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
from src.pdd_purchase_adapter import (
|
from src.pdd_purchase_adapter import (
|
||||||
PddLivePurchaseAdapter,
|
PddLivePurchaseAdapter,
|
||||||
@@ -15,7 +15,8 @@ from src.pdd_purchase_adapter import (
|
|||||||
)
|
)
|
||||||
from src.pdd_purchase_reconcile_adapter import (
|
from src.pdd_purchase_reconcile_adapter import (
|
||||||
PddPurchaseReconcileAdapter,
|
PddPurchaseReconcileAdapter,
|
||||||
PurchaseReconcileObservation,
|
PurchaseOrderCandidate,
|
||||||
|
PurchaseReconcileScan,
|
||||||
)
|
)
|
||||||
from src.purchase_task_service import PurchaseTaskService
|
from src.purchase_task_service import PurchaseTaskService
|
||||||
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
||||||
@@ -26,7 +27,9 @@ from src.task_repository import TaskRepository
|
|||||||
OPTIONS = {"color": "黑色", "size": "L"}
|
OPTIONS = {"color": "黑色", "size": "L"}
|
||||||
|
|
||||||
|
|
||||||
def purchase_admin_task(task_id: str = "PUR-RECOVER") -> AdminTask:
|
def purchase_admin_task(
|
||||||
|
task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
|
||||||
|
) -> AdminTask:
|
||||||
return AdminTask(
|
return AdminTask(
|
||||||
task_id,
|
task_id,
|
||||||
TaskType.PURCHASE,
|
TaskType.PURCHASE,
|
||||||
@@ -39,6 +42,7 @@ def purchase_admin_task(task_id: str = "PUR-RECOVER") -> AdminTask:
|
|||||||
"quantity": 2,
|
"quantity": 2,
|
||||||
"max_price_cent": 5000,
|
"max_price_cent": 5000,
|
||||||
},
|
},
|
||||||
|
execution_mode=execution_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,13 +99,37 @@ class FaultAdapter(PddPurchaseAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
|
class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
|
||||||
def __init__(self, calls) -> None:
|
def __init__(self, calls, candidates=None, error=None) -> None:
|
||||||
self.calls = calls
|
self.calls = calls
|
||||||
|
self.candidates = candidates
|
||||||
|
self.error = error
|
||||||
|
|
||||||
def read_order_match(self, query):
|
def read_order_candidates(self, query):
|
||||||
self.calls.append(("reconcile", query.goods_id))
|
self.calls.append(("reconcile", query.goods_id))
|
||||||
return PurchaseReconcileObservation(
|
if self.error is not None:
|
||||||
"matched", "ORDER-001", "2026-08-10T08:00:00Z"
|
raise self.error
|
||||||
|
candidates = self.candidates
|
||||||
|
if candidates is None:
|
||||||
|
candidates = (
|
||||||
|
PurchaseOrderCandidate(
|
||||||
|
order_no="ORDER-001",
|
||||||
|
goods_id=query.goods_id,
|
||||||
|
options=dict(query.options),
|
||||||
|
quantity=query.quantity,
|
||||||
|
total_price_cent=query.total_price_cent,
|
||||||
|
ordered_at=query.irreversible_action_at,
|
||||||
|
ordered_at_raw="2026-08-10 16:00:00",
|
||||||
|
payment_status="unpaid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates = tuple(
|
||||||
|
replace(candidate, ordered_at=query.irreversible_action_at)
|
||||||
|
for candidate in candidates
|
||||||
|
)
|
||||||
|
return PurchaseReconcileScan(
|
||||||
|
candidates=candidates,
|
||||||
|
diagnostics={"pages_scanned": 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
@@ -119,12 +147,19 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def _add(self, task_id: str = "PUR-RECOVER") -> None:
|
def _add(
|
||||||
task = purchase_admin_task(task_id)
|
self, task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
|
||||||
|
) -> None:
|
||||||
|
task = purchase_admin_task(task_id, execution_mode)
|
||||||
self.gateway.enqueue_task(task, self.client.client_id)
|
self.gateway.enqueue_task(task, self.client.client_id)
|
||||||
claimed = self.gateway.claim_next(
|
claimed = self.gateway.claim_next(
|
||||||
self.client,
|
self.client,
|
||||||
ClaimCapabilities(supported_types=(TaskType.PURCHASE,)),
|
ClaimCapabilities(
|
||||||
|
supported_types=(TaskType.PURCHASE,),
|
||||||
|
purchase_mode=(
|
||||||
|
"live" if execution_mode == "live" else "dry_run"
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert claimed is not None
|
assert claimed is not None
|
||||||
self.repository.add_claimed_task(
|
self.repository.add_claimed_task(
|
||||||
@@ -142,18 +177,18 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _interrupt_after_irreversible(self, task_id: str) -> None:
|
def _interrupt_after_irreversible(self, task_id: str) -> None:
|
||||||
self._add(task_id)
|
self._add(task_id, "live")
|
||||||
started = self.repository.start_purchase_run(task_id, "USB-001")
|
started = self.repository.start_purchase_run(task_id, "USB-001")
|
||||||
connection = open_database(self.db_path)
|
self.repository.mark_purchase_irreversible(
|
||||||
try:
|
task_id,
|
||||||
with connection:
|
started.attempt_id,
|
||||||
connection.execute(
|
{
|
||||||
"UPDATE task_runs SET irreversible_action_at = ?"
|
"options": dict(OPTIONS),
|
||||||
" WHERE attempt_id = ?",
|
"quantity": 2,
|
||||||
("2026-08-10T08:00:00Z", started.attempt_id),
|
"unit_price_cent": 4200,
|
||||||
)
|
"total_price_cent": 8400,
|
||||||
finally:
|
},
|
||||||
connection.close()
|
)
|
||||||
self.repository.recover_interrupted_work()
|
self.repository.recover_interrupted_work()
|
||||||
|
|
||||||
def test_critical_action_failure_keeps_last_persisted_step(self):
|
def test_critical_action_failure_keeps_last_persisted_step(self):
|
||||||
@@ -240,17 +275,36 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
first = dispatcher.execute_one()
|
first = dispatcher.execute_one()
|
||||||
|
pending = self.repository.next_pending_outbox()
|
||||||
|
matched_detail = self.repository.get_task("PUR-RECOVER")
|
||||||
|
matched_run = self.repository.latest_task_run("PUR-RECOVER")
|
||||||
|
duplicate = self.repository.save_matched_purchase_reconciliation(
|
||||||
|
"PUR-RECOVER",
|
||||||
|
matched_run.attempt_id,
|
||||||
|
matched_detail.pdd_data,
|
||||||
|
{"mode": "reconcile_only"},
|
||||||
|
)
|
||||||
|
self.assertEqual(duplicate.id, pending.id)
|
||||||
second = dispatcher.execute_one()
|
second = dispatcher.execute_one()
|
||||||
|
third = dispatcher.execute_one()
|
||||||
|
|
||||||
self.assertEqual(first.kind, "manual_review")
|
self.assertEqual(first.kind, "result_pending")
|
||||||
self.assertEqual(second.kind, "no_task")
|
self.assertEqual(second.kind, "succeeded")
|
||||||
|
self.assertEqual(third.kind, "no_task")
|
||||||
self.assertNotIn(("purchase",), calls)
|
self.assertNotIn(("purchase",), calls)
|
||||||
self.assertEqual(calls.count(("reconcile", "737116531267")), 1)
|
self.assertEqual(calls.count(("reconcile", "737116531267")), 1)
|
||||||
detail = self.repository.get_task("PUR-RECOVER")
|
detail = self.repository.get_task("PUR-RECOVER")
|
||||||
run = self.repository.latest_task_run("PUR-RECOVER")
|
run = self.repository.latest_task_run("PUR-RECOVER")
|
||||||
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_completed")
|
self.assertEqual(detail.current_step, "completed")
|
||||||
self.assertEqual(run.diagnostics_json["mode"], "reconcile_only")
|
self.assertEqual(
|
||||||
|
run.diagnostics_json["reconciliation"]["mode"],
|
||||||
|
"reconcile_only",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
detail.pdd_data["purchase"]["payment_status"], "unpaid"
|
||||||
|
)
|
||||||
|
self.assertEqual(detail.pdd_data["purchase"]["order_no"], "ORDER-001")
|
||||||
|
|
||||||
def test_reconcile_device_failure_is_recorded_as_unknown(self):
|
def test_reconcile_device_failure_is_recorded_as_unknown(self):
|
||||||
task_id = "PUR-RECONCILE-OFFLINE"
|
task_id = "PUR-RECONCILE-OFFLINE"
|
||||||
@@ -278,7 +332,136 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
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.assertIn("核对设备已断开", run.diagnostics_json["error"])
|
self.assertIn(
|
||||||
|
"核对设备已断开",
|
||||||
|
run.diagnostics_json["reconciliation"]["error_message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
|
||||||
|
cases = {
|
||||||
|
"EMPTY": ((), "ORDER_NOT_FOUND"),
|
||||||
|
"MULTIPLE": (
|
||||||
|
(
|
||||||
|
PurchaseOrderCandidate(
|
||||||
|
"ORDER-A",
|
||||||
|
"737116531267",
|
||||||
|
OPTIONS,
|
||||||
|
2,
|
||||||
|
8400,
|
||||||
|
"2026-08-10T08:00:00Z",
|
||||||
|
"2026-08-10 16:00:00",
|
||||||
|
"unpaid",
|
||||||
|
),
|
||||||
|
PurchaseOrderCandidate(
|
||||||
|
"ORDER-B",
|
||||||
|
"737116531267",
|
||||||
|
OPTIONS,
|
||||||
|
2,
|
||||||
|
8400,
|
||||||
|
"2026-08-10T08:00:00Z",
|
||||||
|
"2026-08-10 16:00:00",
|
||||||
|
"unpaid",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"AMBIGUOUS_ORDER_MATCH",
|
||||||
|
),
|
||||||
|
"MISMATCH": (
|
||||||
|
(
|
||||||
|
PurchaseOrderCandidate(
|
||||||
|
"ORDER-C",
|
||||||
|
"OTHER-GOODS",
|
||||||
|
OPTIONS,
|
||||||
|
2,
|
||||||
|
8400,
|
||||||
|
"2026-08-10T08:00:00Z",
|
||||||
|
"2026-08-10 16:00:00",
|
||||||
|
"unpaid",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ORDER_MATCH_UNCERTAIN",
|
||||||
|
),
|
||||||
|
"PAID": (
|
||||||
|
(
|
||||||
|
PurchaseOrderCandidate(
|
||||||
|
"ORDER-D",
|
||||||
|
"737116531267",
|
||||||
|
OPTIONS,
|
||||||
|
2,
|
||||||
|
8400,
|
||||||
|
"2026-08-10T08:00:00Z",
|
||||||
|
"2026-08-10 16:00:00",
|
||||||
|
"paid",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ORDER_MATCH_UNCERTAIN",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for suffix, (candidates, error_code) in cases.items():
|
||||||
|
with self.subTest(suffix=suffix):
|
||||||
|
task_id = f"PUR-{suffix}"
|
||||||
|
self._interrupt_after_irreversible(task_id)
|
||||||
|
calls = []
|
||||||
|
outcome = TaskDispatcher(
|
||||||
|
self.gateway,
|
||||||
|
self.repository,
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
purchase_adapter_factory=lambda *_args: calls.append(
|
||||||
|
"purchase"
|
||||||
|
),
|
||||||
|
purchase_reconcile_factory=(
|
||||||
|
lambda _address, _cancelled, values=candidates:
|
||||||
|
ReadOnlyReconcileAdapter(calls, values)
|
||||||
|
),
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertEqual(outcome.kind, "manual_review")
|
||||||
|
self.assertNotIn("purchase", calls)
|
||||||
|
detail = self.repository.get_task(task_id)
|
||||||
|
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
||||||
|
self.assertEqual(detail.last_error_code, error_code)
|
||||||
|
self.assertIsNone(self.repository.next_pending_outbox())
|
||||||
|
|
||||||
|
def test_restart_after_match_only_submits_outbox_without_reading_phone(self):
|
||||||
|
task_id = "PUR-OUTBOX-RESTART"
|
||||||
|
self._interrupt_after_irreversible(task_id)
|
||||||
|
first_calls = []
|
||||||
|
first = TaskDispatcher(
|
||||||
|
self.gateway,
|
||||||
|
self.repository,
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
purchase_reconcile_factory=(
|
||||||
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
|
||||||
|
first_calls
|
||||||
|
)
|
||||||
|
),
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
|
).execute_one()
|
||||||
|
self.assertEqual(first.kind, "result_pending")
|
||||||
|
|
||||||
|
restarted_repository = TaskRepository(self.db_path)
|
||||||
|
restarted_calls = []
|
||||||
|
second = TaskDispatcher(
|
||||||
|
self.gateway,
|
||||||
|
restarted_repository,
|
||||||
|
self.client,
|
||||||
|
"",
|
||||||
|
purchase_adapter_factory=lambda *_args: restarted_calls.append(
|
||||||
|
"purchase"
|
||||||
|
),
|
||||||
|
purchase_reconcile_factory=lambda *_args: restarted_calls.append(
|
||||||
|
"reconcile"
|
||||||
|
),
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertEqual(second.kind, "succeeded")
|
||||||
|
self.assertEqual(restarted_calls, [])
|
||||||
|
self.assertEqual(
|
||||||
|
restarted_repository.get_task(task_id).status,
|
||||||
|
TaskStatus.SUCCEEDED,
|
||||||
|
)
|
||||||
|
|
||||||
def test_live_submit_is_isolated_and_payment_methods_are_unavailable(self):
|
def test_live_submit_is_isolated_and_payment_methods_are_unavailable(self):
|
||||||
self.assertEqual(ClaimCapabilities(purchase_mode="live").purchase_mode, "live")
|
self.assertEqual(ClaimCapabilities(purchase_mode="live").purchase_mode, "live")
|
||||||
|
|||||||
@@ -122,8 +122,13 @@ class TaskDetailViewTest(unittest.TestCase):
|
|||||||
def test_purchase_recovery_steps_are_clear_chinese(self):
|
def test_purchase_recovery_steps_are_clear_chinese(self):
|
||||||
cases = {
|
cases = {
|
||||||
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
||||||
"reconcile_purchase": "只允许核对订单",
|
"reconcile_purchase": "订单已提交,只允许核对未付款订单",
|
||||||
"reconcile_manual_review": "核对结果不确定,需人工处理",
|
"purchase_order_matched_pending_report": (
|
||||||
|
"已提交待付款,等待向 Admin 上报"
|
||||||
|
),
|
||||||
|
"reconcile_manual_review": (
|
||||||
|
"订单结果不确定或存在多个候选,需人工处理"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for step, expected in cases.items():
|
for step, expected in cases.items():
|
||||||
with self.subTest(step=step):
|
with self.subTest(step=step):
|
||||||
|
|||||||
@@ -113,10 +113,21 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
"PURCHASE-LIVE", "192.168.0.173:5555"
|
"PURCHASE-LIVE", "192.168.0.173:5555"
|
||||||
)
|
)
|
||||||
marked_at = self.repository.mark_purchase_irreversible(
|
marked_at = self.repository.mark_purchase_irreversible(
|
||||||
"PURCHASE-LIVE", started.attempt_id
|
"PURCHASE-LIVE",
|
||||||
|
started.attempt_id,
|
||||||
|
{
|
||||||
|
"options": {"color": "黑色", "size": "L"},
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price_cent": 3990,
|
||||||
|
"total_price_cent": 7980,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
run = self.repository.latest_task_run("PURCHASE-LIVE")
|
run = self.repository.latest_task_run("PURCHASE-LIVE")
|
||||||
self.assertEqual(run.irreversible_action_at, marked_at)
|
self.assertEqual(run.irreversible_action_at, marked_at)
|
||||||
|
self.assertEqual(
|
||||||
|
run.diagnostics_json["final_confirmation"]["total_price_cent"],
|
||||||
|
7980,
|
||||||
|
)
|
||||||
with self.assertRaisesRegex(ValueError, "已经存在"):
|
with self.assertRaisesRegex(ValueError, "已经存在"):
|
||||||
self.repository.mark_purchase_irreversible(
|
self.repository.mark_purchase_irreversible(
|
||||||
"PURCHASE-LIVE", started.attempt_id
|
"PURCHASE-LIVE", started.attempt_id
|
||||||
|
|||||||
@@ -371,7 +371,9 @@ Admin 侧必须无条件接受,见 [04](04-admin-api-contract.md) §6.1。
|
|||||||
`claimed`;恢复执行必须创建新的 `attempt_id`,不会存在两条并发运行。
|
`claimed`;恢复执行必须创建新的 `attempt_id`,不会存在两条并发运行。
|
||||||
- `irreversible_action_at` 有值时,启动恢复立即转为 `manual_review / reconcile_purchase`。
|
- `irreversible_action_at` 有值时,启动恢复立即转为 `manual_review / reconcile_purchase`。
|
||||||
`PurchaseReconcileService` 只能调用独立的只读 Adapter,不会调用采购 Adapter。
|
`PurchaseReconcileService` 只能调用独立的只读 Adapter,不会调用采购 Adapter。
|
||||||
- 核对到唯一候选也仍需人工最终确认;未找到、多候选或结果不确定均保持人工处理。
|
- 核单按商品编号、完整规格、数量、确认总价、提交时间范围和未付款状态严格匹配。
|
||||||
|
只有唯一候选会原子写入采购结果与 Outbox;未找到、多候选、字段不符、非未付款
|
||||||
|
或结果不确定均保持人工处理。
|
||||||
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
|
- Admin 提交失败只重试 Outbox,不再次操作拼多多。
|
||||||
|
|
||||||
## 9. PDD 适配边界
|
## 9. PDD 适配边界
|
||||||
@@ -392,9 +394,14 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
|||||||
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
|
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
|
||||||
都只进入订单核对。该接口不提供付款或取消订单方法。
|
都只进入订单核对。该接口不提供付款或取消订单方法。
|
||||||
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
||||||
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_match`
|
只读核对另用 `PddPurchaseReconcileAdapter`,只暴露 `read_order_candidates`
|
||||||
和 `close`,不暴露选规格、设数量、下单或付款方法。
|
和 `close`,不暴露选规格、设数量、下单或付款方法。
|
||||||
|
|
||||||
|
正式只读核单由 `pdd_u2_purchase_reconcile_adapter.py` 实现,只允许使用白名单
|
||||||
|
导航进入个人中心、我的订单和待付款列表,以及返回和滚动。它只提取核单所需的
|
||||||
|
订单编号、商品编号、规格、数量、总价、下单时间和付款状态,不保存收货人、地址
|
||||||
|
或电话。最终匹配由领域服务完成,不能让页面解析层单独决定采购成功。
|
||||||
|
|
||||||
正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在
|
正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在
|
||||||
`ui_main.py` 注入工厂。Adapter 通过 `PddDeviceService` 独占连接,每次判断
|
`ui_main.py` 注入工厂。Adapter 通过 `PddDeviceService` 独占连接,每次判断
|
||||||
都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ CREATE TABLE app_settings (
|
|||||||
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
|
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
|
||||||
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
|
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
|
||||||
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
|
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
|
||||||
|
| `manual_review / reconcile_purchase` | `result_pending` | 严格核对到唯一未付款订单,结果和 Outbox 已在同一事务落库 | 只读核单服务 |
|
||||||
| `retry_wait` 重试等待 | `running` | 重新启动获取任务,或用户确认“重新执行”;**本地直接重跑**并新建 `task_runs` 记录 | 任务协调器 / 人 |
|
| `retry_wait` 重试等待 | `running` | 重新启动获取任务,或用户确认“重新执行”;**本地直接重跑**并新建 `task_runs` 记录 | 任务协调器 / 人 |
|
||||||
| `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 |
|
| `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 |
|
||||||
| `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 |
|
| `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 |
|
||||||
@@ -588,6 +589,7 @@ CREATE TABLE app_settings (
|
|||||||
"confirmation_reached": true,
|
"confirmation_reached": true,
|
||||||
"order_submitted": false,
|
"order_submitted": false,
|
||||||
"payment_attempted": false,
|
"payment_attempted": false,
|
||||||
|
"payment_status": null,
|
||||||
"order_no": null,
|
"order_no": null,
|
||||||
"ordered_at": null,
|
"ordered_at": null,
|
||||||
"ordered_at_raw": null,
|
"ordered_at_raw": null,
|
||||||
@@ -601,7 +603,10 @@ CREATE TABLE app_settings (
|
|||||||
`order_submitted=false`、`payment_attempted=false` 和
|
`order_submitted=false`、`payment_attempted=false` 和
|
||||||
`match_status=not_submitted`;只表示已经安全到达最终提交前确认页。
|
`match_status=not_submitted`;只表示已经安全到达最终提交前确认页。
|
||||||
|
|
||||||
真实下单后 `mode` 为 `live`,`match_status` 只能是 `matched`、`ambiguous` 或 `not_found`。只有 `matched` 可以自动报告采购成功。
|
真实下单成功核对后 `mode` 为 `live`、`payment_status` 为 `unpaid`、
|
||||||
|
`match_status` 为 `matched`。只有商品编号、完整规格、数量、总价、时间范围和未付款
|
||||||
|
状态全部一致,且候选唯一时才生成采购结果。无候选、多候选、字段不符或非未付款
|
||||||
|
订单只保存脱敏诊断并进入人工处理,不生成成功 `pdd_data`。
|
||||||
|
|
||||||
## 9. JSON 兼容规则
|
## 9. JSON 兼容规则
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,11 @@ Idempotency-Key: task-id:attempt-id:result-v1
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
采购结果把 `result_type` 换成 `purchase`,其余结构相同。
|
采购结果把 `result_type` 换成 `purchase`,并按 [03 数据模型 §8.2](03-data-model.md)
|
||||||
|
携带 `purchase`。真实采购成功必须同时满足 `mode=live`、
|
||||||
|
`order_submitted=true`、`payment_attempted=false`、`payment_status=unpaid`、
|
||||||
|
`match_status=matched`,并包含非空订单编号和带时区下单时间。0 个或多个候选、
|
||||||
|
字段不符及非未付款订单改走 §7 人工处理,不得提交采购成功结果。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。
|
详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。
|
||||||
采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、
|
采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、
|
||||||
“只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。
|
“只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。
|
||||||
|
真实提交后的详情还要区分“订单已提交,只允许核对未付款订单”、
|
||||||
|
“已提交待付款,等待向 Admin 上报”和“订单结果不确定或存在多个候选”。
|
||||||
|
|
||||||
采集规格按下面顺序展示:
|
采集规格按下面顺序展示:
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ PDD 解析测试优先使用脱敏的 XML 固件,不要求每次连接真实
|
|||||||
- 关键手机动作前已持久化 `current_step`。
|
- 关键手机动作前已持久化 `current_step`。
|
||||||
- 无不可逆标记的中断会先关闭旧运行,新运行使用新 `attempt_id`。
|
- 无不可逆标记的中断会先关闭旧运行,新运行使用新 `attempt_id`。
|
||||||
- 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。
|
- 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。
|
||||||
|
- 只读核单按商品、完整规格、数量、总价、时间和未付款状态全部精确匹配;只有唯一候选生成成功 Outbox。
|
||||||
|
- 无候选、多候选、字段缺失或不符、非未付款状态只转人工,不产生采购成功结果。
|
||||||
- 已落库 Outbox 只补交,不重跑手机流程。
|
- 已落库 Outbox 只补交,不重跑手机流程。
|
||||||
- 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。
|
- 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。
|
||||||
- `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。
|
- `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。
|
||||||
|
|||||||
Reference in New Issue
Block a user