From 399aa0ebc6ce17b9d84f51ad32fc152c8887f3d3 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 20:13:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AE=80=E5=8C=96=E5=BE=85=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E8=AE=A2=E5=8D=95=E6=A0=B8=E5=8D=95=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_purchase_reconcile_adapter.py | 1 + client/src/purchase_reconcile_service.py | 57 +++++++++----- .../test_pdd_u2_purchase_reconcile_adapter.py | 24 ++++++ client/test/test_purchase_recovery.py | 75 +++++++++++++------ docs/client/01-requirements.md | 6 +- docs/client/02-architecture.md | 14 ++-- docs/client/03-data-model.md | 6 +- docs/client/04-admin-api-contract.md | 4 +- docs/client/06-quality-security.md | 6 +- 9 files changed, 141 insertions(+), 52 deletions(-) diff --git a/client/src/pdd_purchase_reconcile_adapter.py b/client/src/pdd_purchase_reconcile_adapter.py index 4ebcb43..6e42e42 100644 --- a/client/src/pdd_purchase_reconcile_adapter.py +++ b/client/src/pdd_purchase_reconcile_adapter.py @@ -18,6 +18,7 @@ class PurchaseReconcileQuery: unit_price_cent: int total_price_cent: int irreversible_action_at: str + order_submitted_at: str reconcile_started_at: str diff --git a/client/src/purchase_reconcile_service.py b/client/src/purchase_reconcile_service.py index 9b6cdfb..2758839 100644 --- a/client/src/purchase_reconcile_service.py +++ b/client/src/purchase_reconcile_service.py @@ -208,21 +208,14 @@ class PurchaseReconcileService: ) -> 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 - ) + submitted_at = _parse_iso_time(query.order_submitted_at) + lower = submitted_at - timedelta(minutes=5) + upper = submitted_at + timedelta(minutes=5) except (TypeError, ValueError): return False return lower <= ordered_at <= upper @@ -237,21 +230,41 @@ class PurchaseReconcileService: options = payload.get("options") if not isinstance(options, Mapping) or not 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() - quantity = payload.get("quantity") + requested_quantity = payload.get("quantity") snapshot = run.diagnostics_json.get("final_confirmation") if not isinstance(snapshot, Mapping): raise ValueError("采购运行缺少提交前确认快照") + confirmed_options = snapshot.get("options") + confirmed_quantity = snapshot.get("quantity") total_price_cent = snapshot.get("total_price_cent") unit_price_cent = snapshot.get("unit_price_cent") if not goods_id: raise ValueError("采购任务缺少 goods_id") if ( - isinstance(quantity, bool) - or not isinstance(quantity, int) - or quantity <= 0 + isinstance(requested_quantity, bool) + or not isinstance(requested_quantity, int) + or requested_quantity <= 0 ): 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 ( isinstance(total_price_cent, bool) or not isinstance(total_price_cent, int) @@ -264,13 +277,19 @@ class PurchaseReconcileService: or unit_price_cent <= 0 ): 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( goods_id=goods_id, - options={str(key): str(value) for key, value in options.items()}, - quantity=quantity, + options=normalized_confirmed_options, + quantity=confirmed_quantity, unit_price_cent=unit_price_cent, total_price_cent=total_price_cent, irreversible_action_at=str(run.irreversible_action_at), + order_submitted_at=order_submitted_at, reconcile_started_at=_utc_now_iso(), ) @@ -292,10 +311,10 @@ class PurchaseReconcileService: "max_price_cent": task.price_cent, }, "confirmed": { - "options": dict(candidate.options), - "quantity": candidate.quantity, + "options": dict(query.options), + "quantity": query.quantity, "unit_price_cent": query.unit_price_cent, - "total_price_cent": candidate.total_price_cent, + "total_price_cent": query.total_price_cent, }, "confirmation_reached": True, "order_submitted": True, diff --git a/client/test/test_pdd_u2_purchase_reconcile_adapter.py b/client/test/test_pdd_u2_purchase_reconcile_adapter.py index 02ca766..0279911 100644 --- a/client/test/test_pdd_u2_purchase_reconcile_adapter.py +++ b/client/test/test_pdd_u2_purchase_reconcile_adapter.py @@ -26,6 +26,7 @@ def query() -> PurchaseReconcileQuery: unit_price_cent=4200, total_price_cent=8400, irreversible_action_at="2026-08-10T08:00:00Z", + order_submitted_at="2026-08-10T08:03:00Z", reconcile_started_at="2026-08-10T08:05:00Z", ) @@ -235,6 +236,29 @@ class PddU2PurchaseReconcileAdapterTest(unittest.TestCase): 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): root = _parse_xml( ' None: + def __init__( + self, calls, candidates=None, error=None, preserve_times=False + ) -> None: self.calls = calls self.candidates = candidates self.error = error + self.preserve_times = preserve_times def read_order_candidates(self, query): self.calls.append(("reconcile", query.goods_id)) @@ -113,18 +116,14 @@ class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter): 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=query.order_submitted_at, ordered_at_raw="2026-08-10 16:00:00", payment_status="unpaid", ), ) - else: + elif not self.preserve_times: candidates = tuple( - replace(candidate, ordered_at=query.irreversible_action_at) + replace(candidate, ordered_at=query.order_submitted_at) for candidate in candidates ) return PurchaseReconcileScan( @@ -305,6 +304,15 @@ class PurchaseRecoveryTest(unittest.TestCase): detail.pdd_data["purchase"]["payment_status"], "unpaid" ) 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): 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): cases = { - "EMPTY": ((), "ORDER_NOT_FOUND"), + "EMPTY": ((), "ORDER_NOT_FOUND", False), "MULTIPLE": ( ( PurchaseOrderCandidate( @@ -367,21 +375,19 @@ class PurchaseRecoveryTest(unittest.TestCase): ), ), "AMBIGUOUS_ORDER_MATCH", + False, ), - "MISMATCH": ( + "OUT_OF_TIME": ( ( PurchaseOrderCandidate( - "ORDER-C", - "OTHER-GOODS", - OPTIONS, - 2, - 8400, - "2026-08-10T08:00:00Z", - "2026-08-10 16:00:00", - "unpaid", + order_no="ORDER-C", + ordered_at="2020-01-01T00:00:00Z", + ordered_at_raw="2020-01-01 08:00:00", + payment_status="unpaid", ), ), "ORDER_MATCH_UNCERTAIN", + True, ), "PAID": ( ( @@ -397,9 +403,31 @@ class PurchaseRecoveryTest(unittest.TestCase): ), ), "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): task_id = f"PUR-{suffix}" self._interrupt_after_irreversible(task_id) @@ -413,8 +441,13 @@ class PurchaseRecoveryTest(unittest.TestCase): "purchase" ), purchase_reconcile_factory=( - lambda _address, _cancelled, values=candidates: - ReadOnlyReconcileAdapter(calls, values) + lambda _address, _cancelled, values=candidates, + keep_times=preserve_times: + ReadOnlyReconcileAdapter( + calls, + values, + preserve_times=keep_times, + ) ), device_connection_checker=lambda _serial: None, ).execute_one() diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 5839b9a..84bc410 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -85,7 +85,11 @@ Client 应执行: 5. 获取并核对订单编号和下单时间。 6. 保存本地结果并提交 Admin。 -仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。必须结合任务开始时间、商品、规格、数量和金额核对;存在多个候选时进入人工处理状态,不得猜测或重新下单。 +仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。订单页必须读到 +非空订单编号、有效下单时间和待付款状态;下单时间须位于本地 +`order_submitted_at` 前后 5 分钟,且窗口内只能有一个候选。商品、规格、数量和金额 +使用下单前已持久化的确认快照,不要求订单页重复提供。存在多个候选时进入人工处理 +状态,不得猜测或重新下单。 ## 5. PDD 任务页需求 diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 9286815..e90b57b 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -382,9 +382,10 @@ Admin 侧必须无条件接受,见 [04](04-admin-api-contract.md) §6.1。 `claimed`;恢复执行必须创建新的 `attempt_id`,不会存在两条并发运行。 - `irreversible_action_at` 有值时,启动恢复立即转为 `manual_review / reconcile_purchase`。 `PurchaseReconcileService` 只能调用独立的只读 Adapter,不会调用采购 Adapter。 -- 核单按商品编号、完整规格、数量、确认总价、提交时间范围和未付款状态严格匹配。 - 只有唯一候选会原子写入采购结果与 Outbox;未找到、多候选、字段不符、非未付款 - 或结果不确定均保持人工处理。 +- 核单要求订单页提供非空订单编号、有效下单时间和未付款状态;下单时间必须位于 + 本地 `order_submitted_at` 前后 5 分钟,且窗口内只有一个候选。商品编号来自任务, + 规格、数量和确认总价来自不可逆动作前持久化的 `final_confirmation`,不要求订单页 + 重复提供。未找到、多候选、订单号或时间缺失、非未付款或结果不确定均保持人工处理。 - Admin 提交失败只重试 Outbox,不再次操作拼多多。 ## 9. PDD 适配边界 @@ -409,9 +410,10 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview 和 `close`,不暴露选规格、设数量、下单或付款方法。 正式只读核单由 `pdd_u2_purchase_reconcile_adapter.py` 实现,只允许使用白名单 -导航进入个人中心、我的订单和待付款列表,以及返回和滚动。它只提取核单所需的 -订单编号、商品编号、规格、数量、总价、下单时间和付款状态,不保存收货人、地址 -或电话。最终匹配由领域服务完成,不能让页面解析层单独决定采购成功。 +导航进入个人中心、我的订单和待付款列表,以及返回和滚动。订单匹配只使用页面中的 +订单编号、下单时间和付款状态;页面上出现的商品或金额文字可以解析诊断,但不能覆盖 +下单前确认快照。Client 不保存收货人、地址或电话。最终匹配由领域服务完成,不能让 +页面解析层单独决定采购成功。 正式 Client 由 `pdd_u2_purchase_adapter.py` 分别实现演练和 live 接口,并在 `ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的 diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index 972b1ec..238e665 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -612,8 +612,10 @@ CREATE TABLE app_settings ( `match_status=not_submitted`;只表示已经安全到达最终提交前确认页。 真实下单成功核对后 `mode` 为 `live`、`payment_status` 为 `unpaid`、 -`match_status` 为 `matched`。只有商品编号、完整规格、数量、总价、时间范围和未付款 -状态全部一致,且候选唯一时才生成采购结果。无候选、多候选、字段不符或非未付款 +`match_status` 为 `matched`。订单页必须提供非空订单编号、有效下单时间和未付款 +状态;下单时间位于本地 `order_submitted_at` 前后 5 分钟且候选唯一时才生成采购 +结果。`goods_id` 来自原任务,`confirmed.options`、数量和金额来自下单前持久化的 +`final_confirmation`。无候选、多候选、订单号或时间缺失、超出时间窗口或非未付款 订单只保存脱敏诊断并进入人工处理,不生成成功 `pdd_data`。 ## 9. JSON 兼容规则 diff --git a/docs/client/04-admin-api-contract.md b/docs/client/04-admin-api-contract.md index 65edcec..4056100 100644 --- a/docs/client/04-admin-api-contract.md +++ b/docs/client/04-admin-api-contract.md @@ -319,7 +319,9 @@ Idempotency-Key: task-id:attempt-id:result-v1 携带 `purchase`。真实采购成功必须同时满足 `mode=live`、 `order_submitted=true`、`payment_attempted=false`、`payment_status=unpaid`、 `match_status=matched`,并包含非空订单编号和带时区下单时间。0 个或多个候选、 -字段不符及非未付款订单改走 §7 人工处理,不得提交采购成功结果。 +订单编号或时间无效、下单时间超出本地提交时间前后 5 分钟及非未付款订单改走 +§7 人工处理,不得提交采购成功结果。商品、规格、数量和金额使用任务及下单前确认 +快照,不要求订单详情页面重复提供。 响应: diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 2fb16a9..e8462e2 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -88,8 +88,10 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` - 关键手机动作前已持久化 `current_step`。 - 无不可逆标记的中断会先关闭旧运行,新运行使用新 `attempt_id`。 - 有不可逆标记的中断只调用只读核对,采购 Adapter 调用次数为 0。 -- 只读核单按商品、完整规格、数量、总价、时间和未付款状态全部精确匹配;只有唯一候选生成成功 Outbox。 -- 无候选、多候选、字段缺失或不符、非未付款状态只转人工,不产生采购成功结果。 +- 只读核单要求非空订单编号、有效下单时间和未付款状态;时间位于本地 + `order_submitted_at` 前后 5 分钟且候选唯一时才生成成功 Outbox。 +- 商品和确认快照不依赖订单页重复展示;无候选、多候选、订单号或时间缺失、 + 超出时间窗口或非未付款状态只转人工,不产生采购成功结果。 - 已落库 Outbox 只补交,不重跑手机流程。 - 停止、设备断开、验证码、登录失效和结果不确定都保留稳定错误和诊断。 - `execution_mode=live` 只允许采购任务,且 Client 不得自行升级 Admin 下发的模式。