fix: 简化待付款订单核单条件 (#126)

This commit is contained in:
chengma
2026-08-10 20:13:55 +08:00
parent 3600d0cd37
commit 399aa0ebc6
9 changed files with 141 additions and 52 deletions
@@ -18,6 +18,7 @@ class PurchaseReconcileQuery:
unit_price_cent: int unit_price_cent: int
total_price_cent: int total_price_cent: int
irreversible_action_at: str irreversible_action_at: str
order_submitted_at: str
reconcile_started_at: str reconcile_started_at: str
+38 -19
View File
@@ -208,21 +208,14 @@ class PurchaseReconcileService:
) -> bool: ) -> bool:
if ( if (
not candidate.order_no.strip() 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" or candidate.payment_status != "unpaid"
): ):
return False return False
try: try:
ordered_at = _parse_iso_time(candidate.ordered_at) ordered_at = _parse_iso_time(candidate.ordered_at)
lower = _parse_iso_time(query.irreversible_action_at) - timedelta( submitted_at = _parse_iso_time(query.order_submitted_at)
minutes=5 lower = submitted_at - timedelta(minutes=5)
) upper = submitted_at + timedelta(minutes=5)
upper = _parse_iso_time(query.reconcile_started_at) + timedelta(
minutes=5
)
except (TypeError, ValueError): except (TypeError, ValueError):
return False return False
return lower <= ordered_at <= upper return lower <= ordered_at <= upper
@@ -237,21 +230,41 @@ class PurchaseReconcileService:
options = payload.get("options") options = payload.get("options")
if not isinstance(options, Mapping) or not options: if not isinstance(options, Mapping) or not options:
raise ValueError("采购任务缺少 options") raise ValueError("采购任务缺少 options")
requested_options = {
str(key): str(value) for key, value in options.items()
}
goods_id = str(payload.get("goods_id") or "").strip() goods_id = str(payload.get("goods_id") or "").strip()
quantity = payload.get("quantity") requested_quantity = payload.get("quantity")
snapshot = run.diagnostics_json.get("final_confirmation") snapshot = run.diagnostics_json.get("final_confirmation")
if not isinstance(snapshot, Mapping): if not isinstance(snapshot, Mapping):
raise ValueError("采购运行缺少提交前确认快照") raise ValueError("采购运行缺少提交前确认快照")
confirmed_options = snapshot.get("options")
confirmed_quantity = snapshot.get("quantity")
total_price_cent = snapshot.get("total_price_cent") total_price_cent = snapshot.get("total_price_cent")
unit_price_cent = snapshot.get("unit_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 (
isinstance(quantity, bool) isinstance(requested_quantity, bool)
or not isinstance(quantity, int) or not isinstance(requested_quantity, int)
or quantity <= 0 or requested_quantity <= 0
): ):
raise ValueError("采购任务缺少有效 quantity") raise ValueError("采购任务缺少有效 quantity")
if not isinstance(confirmed_options, Mapping):
raise ValueError("采购运行缺少确认规格")
normalized_confirmed_options = {
str(key): str(value) for key, value in confirmed_options.items()
}
if normalized_confirmed_options != requested_options:
raise ValueError("采购运行确认规格与任务不一致")
if (
isinstance(confirmed_quantity, bool)
or not isinstance(confirmed_quantity, int)
or confirmed_quantity <= 0
):
raise ValueError("采购运行缺少有效确认数量")
if confirmed_quantity != requested_quantity:
raise ValueError("采购运行确认数量与任务不一致")
if ( if (
isinstance(total_price_cent, bool) isinstance(total_price_cent, bool)
or not isinstance(total_price_cent, int) or not isinstance(total_price_cent, int)
@@ -264,13 +277,19 @@ class PurchaseReconcileService:
or unit_price_cent <= 0 or unit_price_cent <= 0
): ):
raise ValueError("采购运行缺少有效确认单价") raise ValueError("采购运行缺少有效确认单价")
order_submitted_at = str(
run.order_submitted_at or run.irreversible_action_at or ""
).strip()
if not order_submitted_at:
raise ValueError("采购运行缺少下单时间基准")
return PurchaseReconcileQuery( return PurchaseReconcileQuery(
goods_id=goods_id, goods_id=goods_id,
options={str(key): str(value) for key, value in options.items()}, options=normalized_confirmed_options,
quantity=quantity, quantity=confirmed_quantity,
unit_price_cent=unit_price_cent, unit_price_cent=unit_price_cent,
total_price_cent=total_price_cent, total_price_cent=total_price_cent,
irreversible_action_at=str(run.irreversible_action_at), irreversible_action_at=str(run.irreversible_action_at),
order_submitted_at=order_submitted_at,
reconcile_started_at=_utc_now_iso(), reconcile_started_at=_utc_now_iso(),
) )
@@ -292,10 +311,10 @@ class PurchaseReconcileService:
"max_price_cent": task.price_cent, "max_price_cent": task.price_cent,
}, },
"confirmed": { "confirmed": {
"options": dict(candidate.options), "options": dict(query.options),
"quantity": candidate.quantity, "quantity": query.quantity,
"unit_price_cent": query.unit_price_cent, "unit_price_cent": query.unit_price_cent,
"total_price_cent": candidate.total_price_cent, "total_price_cent": query.total_price_cent,
}, },
"confirmation_reached": True, "confirmation_reached": True,
"order_submitted": True, "order_submitted": True,
@@ -26,6 +26,7 @@ def query() -> PurchaseReconcileQuery:
unit_price_cent=4200, unit_price_cent=4200,
total_price_cent=8400, total_price_cent=8400,
irreversible_action_at="2026-08-10T08:00:00Z", irreversible_action_at="2026-08-10T08:00:00Z",
order_submitted_at="2026-08-10T08:03:00Z",
reconcile_started_at="2026-08-10T08:05:00Z", reconcile_started_at="2026-08-10T08:05:00Z",
) )
@@ -235,6 +236,29 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
self.assertIsNone(_merge_order_evidence(first, second)) self.assertIsNone(_merge_order_evidence(first, second))
def test_minimal_order_and_time_candidate_uses_five_minute_window(self):
within_window = PurchaseOrderCandidate(
order_no="ORDER-MINIMAL",
ordered_at="2026-08-10T08:08:00Z",
payment_status="unpaid",
)
outside_window = PurchaseOrderCandidate(
order_no="ORDER-LATE",
ordered_at="2026-08-10T08:08:01Z",
payment_status="unpaid",
)
self.assertTrue(
PurchaseReconcileService._candidate_matches(
within_window, query()
)
)
self.assertFalse(
PurchaseReconcileService._candidate_matches(
outside_window, query()
)
)
def test_multiple_order_actions_disable_detail_evidence_merge(self): def test_multiple_order_actions_disable_detail_evidence_merge(self):
root = _parse_xml( root = _parse_xml(
'<hierarchy><node package="com.xunmeng.pinduoduo" ' '<hierarchy><node package="com.xunmeng.pinduoduo" '
+54 -21
View File
@@ -99,10 +99,13 @@ class FaultAdapter(PddPurchaseAdapter):
class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter): class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
def __init__(self, calls, candidates=None, error=None) -> None: def __init__(
self, calls, candidates=None, error=None, preserve_times=False
) -> None:
self.calls = calls self.calls = calls
self.candidates = candidates self.candidates = candidates
self.error = error self.error = error
self.preserve_times = preserve_times
def read_order_candidates(self, query): def read_order_candidates(self, query):
self.calls.append(("reconcile", query.goods_id)) self.calls.append(("reconcile", query.goods_id))
@@ -113,18 +116,14 @@ class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
candidates = ( candidates = (
PurchaseOrderCandidate( PurchaseOrderCandidate(
order_no="ORDER-001", order_no="ORDER-001",
goods_id=query.goods_id, ordered_at=query.order_submitted_at,
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", ordered_at_raw="2026-08-10 16:00:00",
payment_status="unpaid", payment_status="unpaid",
), ),
) )
else: elif not self.preserve_times:
candidates = tuple( candidates = tuple(
replace(candidate, ordered_at=query.irreversible_action_at) replace(candidate, ordered_at=query.order_submitted_at)
for candidate in candidates for candidate in candidates
) )
return PurchaseReconcileScan( return PurchaseReconcileScan(
@@ -305,6 +304,15 @@ class PurchaseRecoveryTest(unittest.TestCase):
detail.pdd_data["purchase"]["payment_status"], "unpaid" detail.pdd_data["purchase"]["payment_status"], "unpaid"
) )
self.assertEqual(detail.pdd_data["purchase"]["order_no"], "ORDER-001") self.assertEqual(detail.pdd_data["purchase"]["order_no"], "ORDER-001")
self.assertEqual(
detail.pdd_data["purchase"]["confirmed"],
{
"options": OPTIONS,
"quantity": 2,
"unit_price_cent": 4200,
"total_price_cent": 8400,
},
)
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"
@@ -342,7 +350,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self): def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
cases = { cases = {
"EMPTY": ((), "ORDER_NOT_FOUND"), "EMPTY": ((), "ORDER_NOT_FOUND", False),
"MULTIPLE": ( "MULTIPLE": (
( (
PurchaseOrderCandidate( PurchaseOrderCandidate(
@@ -367,21 +375,19 @@ class PurchaseRecoveryTest(unittest.TestCase):
), ),
), ),
"AMBIGUOUS_ORDER_MATCH", "AMBIGUOUS_ORDER_MATCH",
False,
), ),
"MISMATCH": ( "OUT_OF_TIME": (
( (
PurchaseOrderCandidate( PurchaseOrderCandidate(
"ORDER-C", order_no="ORDER-C",
"OTHER-GOODS", ordered_at="2020-01-01T00:00:00Z",
OPTIONS, ordered_at_raw="2020-01-01 08:00:00",
2, payment_status="unpaid",
8400,
"2026-08-10T08:00:00Z",
"2026-08-10 16:00:00",
"unpaid",
), ),
), ),
"ORDER_MATCH_UNCERTAIN", "ORDER_MATCH_UNCERTAIN",
True,
), ),
"PAID": ( "PAID": (
( (
@@ -397,9 +403,31 @@ class PurchaseRecoveryTest(unittest.TestCase):
), ),
), ),
"ORDER_MATCH_UNCERTAIN", "ORDER_MATCH_UNCERTAIN",
False,
),
"MISSING_ORDER_NO": (
(
PurchaseOrderCandidate(
order_no="",
payment_status="unpaid",
),
),
"ORDER_MATCH_UNCERTAIN",
False,
),
"MISSING_TIME": (
(
PurchaseOrderCandidate(
order_no="ORDER-E",
ordered_at="",
payment_status="unpaid",
),
),
"ORDER_MATCH_UNCERTAIN",
True,
), ),
} }
for suffix, (candidates, error_code) in cases.items(): for suffix, (candidates, error_code, preserve_times) in cases.items():
with self.subTest(suffix=suffix): with self.subTest(suffix=suffix):
task_id = f"PUR-{suffix}" task_id = f"PUR-{suffix}"
self._interrupt_after_irreversible(task_id) self._interrupt_after_irreversible(task_id)
@@ -413,8 +441,13 @@ class PurchaseRecoveryTest(unittest.TestCase):
"purchase" "purchase"
), ),
purchase_reconcile_factory=( purchase_reconcile_factory=(
lambda _address, _cancelled, values=candidates: lambda _address, _cancelled, values=candidates,
ReadOnlyReconcileAdapter(calls, values) keep_times=preserve_times:
ReadOnlyReconcileAdapter(
calls,
values,
preserve_times=keep_times,
)
), ),
device_connection_checker=lambda _serial: None, device_connection_checker=lambda _serial: None,
).execute_one() ).execute_one()
+5 -1
View File
@@ -85,7 +85,11 @@ Client 应执行:
5. 获取并核对订单编号和下单时间。 5. 获取并核对订单编号和下单时间。
6. 保存本地结果并提交 Admin。 6. 保存本地结果并提交 Admin。
仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。必须结合任务开始时间、商品、规格、数量和金额核对;存在多个候选时进入人工处理状态,不得猜测或重新下单。 仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。订单页必须读到
非空订单编号、有效下单时间和待付款状态;下单时间须位于本地
`order_submitted_at` 前后 5 分钟,且窗口内只能有一个候选。商品、规格、数量和金额
使用下单前已持久化的确认快照,不要求订单页重复提供。存在多个候选时进入人工处理
状态,不得猜测或重新下单。
## 5. PDD 任务页需求 ## 5. PDD 任务页需求
+8 -6
View File
@@ -382,9 +382,10 @@ 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;未找到、多候选、字段不符、非未付款 本地 `order_submitted_at` 前后 5 分钟,且窗口内只有一个候选。商品编号来自任务,
或结果不确定均保持人工处理。 规格、数量和确认总价来自不可逆动作前持久化的 `final_confirmation`,不要求订单页
重复提供。未找到、多候选、订单号或时间缺失、非未付款或结果不确定均保持人工处理。
- Admin 提交失败只重试 Outbox,不再次操作拼多多。 - Admin 提交失败只重试 Outbox,不再次操作拼多多。
## 9. PDD 适配边界 ## 9. PDD 适配边界
@@ -409,9 +410,10 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
和 `close`,不暴露选规格、设数量、下单或付款方法。 和 `close`,不暴露选规格、设数量、下单或付款方法。
正式只读核单由 `pdd_u2_purchase_reconcile_adapter.py` 实现,只允许使用白名单 正式只读核单由 `pdd_u2_purchase_reconcile_adapter.py` 实现,只允许使用白名单
导航进入个人中心、我的订单和待付款列表,以及返回和滚动。它只提取核单所需的 导航进入个人中心、我的订单和待付款列表,以及返回和滚动。订单匹配只使用页面中的
订单编号、商品编号、规格、数量、总价、下单时间和付款状态,不保存收货人、地址 订单编号、下单时间和付款状态;页面上出现的商品或金额文字可以解析诊断,但不能覆盖
或电话。最终匹配由领域服务完成,不能让页面解析层单独决定采购成功。 下单前确认快照。Client 不保存收货人、地址或电话。最终匹配由领域服务完成,不能让
页面解析层单独决定采购成功。
正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在 正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在
`ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的 `ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的
+4 -2
View File
@@ -612,8 +612,10 @@ CREATE TABLE app_settings (
`match_status=not_submitted`;只表示已经安全到达最终提交前确认页。 `match_status=not_submitted`;只表示已经安全到达最终提交前确认页。
真实下单成功核对后 `mode` 为 `live`、`payment_status` 为 `unpaid`、 真实下单成功核对后 `mode` 为 `live`、`payment_status` 为 `unpaid`、
`match_status` 为 `matched`。只有商品编号、完整规格、数量、总价、时间范围和未付款 `match_status` 为 `matched`。订单页必须提供非空订单编号、有效下单时间和未付款
状态全部一致,且候选唯一时才生成采购结果。无候选、多候选、字段不符或非未付款 状态;下单时间位于本地 `order_submitted_at` 前后 5 分钟且候选唯一时才生成采购
结果。`goods_id` 来自原任务,`confirmed.options`、数量和金额来自下单前持久化的
`final_confirmation`。无候选、多候选、订单号或时间缺失、超出时间窗口或非未付款
订单只保存脱敏诊断并进入人工处理,不生成成功 `pdd_data`。 订单只保存脱敏诊断并进入人工处理,不生成成功 `pdd_data`。
## 9. JSON 兼容规则 ## 9. JSON 兼容规则
+3 -1
View File
@@ -319,7 +319,9 @@ Idempotency-Key: task-id:attempt-id:result-v1
携带 `purchase`。真实采购成功必须同时满足 `mode=live`、 携带 `purchase`。真实采购成功必须同时满足 `mode=live`、
`order_submitted=true`、`payment_attempted=false`、`payment_status=unpaid`、 `order_submitted=true`、`payment_attempted=false`、`payment_status=unpaid`、
`match_status=matched`,并包含非空订单编号和带时区下单时间。0 个或多个候选、 `match_status=matched`,并包含非空订单编号和带时区下单时间。0 个或多个候选、
字段不符及非未付款订单改走 §7 人工处理,不得提交采购成功结果。 订单编号或时间无效、下单时间超出本地提交时间前后 5 分钟及非未付款订单改走
§7 人工处理,不得提交采购成功结果。商品、规格、数量和金额使用任务及下单前确认
快照,不要求订单详情页面重复提供。
响应: 响应:
+4 -2
View File
@@ -88,8 +88,10 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
- 关键手机动作前已持久化 `current_step`。 - 关键手机动作前已持久化 `current_step`。
- 无不可逆标记的中断会先关闭旧运行,新运行使用新 `attempt_id`。 - 无不可逆标记的中断会先关闭旧运行,新运行使用新 `attempt_id`。
- 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。 - 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。
- 只读核单按商品、完整规格、数量、总价、时间和未付款状态全部精确匹配;只有唯一候选生成成功 Outbox。 - 只读核单要求非空订单编号、有效下单时间和未付款状态;时间位于本地
- 无候选、多候选、字段缺失或不符、非未付款状态只转人工,不产生采购成功结果。 `order_submitted_at` 前后 5 分钟且候选唯一时才生成成功 Outbox。
- 商品和确认快照不依赖订单页重复展示;无候选、多候选、订单号或时间缺失、
超出时间窗口或非未付款状态只转人工,不产生采购成功结果。
- 已落库 Outbox 只补交,不重跑手机流程。 - 已落库 Outbox 只补交,不重跑手机流程。
- 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。 - 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。
- `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。 - `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。