"""不可逆阶段后的只读采购订单核对与结果入队。""" from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Callable, Mapping, Optional from .pdd_purchase_reconcile_adapter import ( PddPurchaseReconcileAdapter, PurchaseOrderCandidate, PurchaseReconcileQuery, PurchaseReconcileScan, ) from .task_models import TaskDetail, TaskRunRecord from .task_repository import TaskRepository PurchaseReconcileFactory = Callable[ [str, Callable[[], bool]], PddPurchaseReconcileAdapter ] 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) class PurchaseReconcileOutcome: """只读核对的简短结果。""" kind: str message: str task_id: str = "" class PurchaseReconcileService: """严格匹配未付款订单;唯一匹配才生成采购成功 Outbox。""" _DIAGNOSTIC_KEYS = frozenset( {"pages_scanned", "candidate_count", "error_code", "error_message"} ) def __init__( self, repository: TaskRepository, device_address: str, adapter_factory: PurchaseReconcileFactory, *, cancelled: Callable[[], bool] = lambda: False, ) -> None: self._repository = repository self._device_address = str(device_address or "").strip() self._factory = adapter_factory self._cancelled = cancelled def execute_selected( self, remote_task_id: str ) -> PurchaseReconcileOutcome: """只读核对一次;唯一未付款订单入 Outbox,其余转人工。""" if not self._device_address: raise ValueError("请先在设置页选择并保存 Android 设备") if self._cancelled(): return PurchaseReconcileOutcome( "cancelled", "采购结果核对已取消", remote_task_id ) task = self._repository.get_task(remote_task_id) run = self._repository.latest_task_run(remote_task_id) if task is None or run is None: raise ValueError(f"任务 {remote_task_id} 或执行记录不存在") if run.irreversible_action_at is None: raise ValueError("未进入不可逆阶段,不应启动订单核对") try: 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 = "" scan = PurchaseReconcileScan() try: adapter = self._factory(self._device_address, self._cancelled) scan = adapter.read_order_candidates(query) if not isinstance(scan, PurchaseReconcileScan): raise TypeError("采购核对 Adapter 返回值无效") if any( not isinstance(candidate, PurchaseOrderCandidate) for candidate in scan.candidates ): raise TypeError("采购核对候选数据无效") except Exception as exc: scan = PurchaseReconcileScan( diagnostics={ "error_code": "RECONCILE_READ_FAILED", "error_message": str(exc)[:300], } ) finally: if adapter is not None: try: adapter.close() except Exception as exc: close_error = str(exc) return scan, close_error def _save_manual_review( self, task: TaskDetail, run: TaskRunRecord, match_status: str, diagnostics: Mapping[str, object], ) -> PurchaseReconcileOutcome: self._repository.save_purchase_reconciliation( task.remote_task_id, run.attempt_id, match_status, dict(diagnostics), ) messages = { "not_found": "未找到符合时间范围的订单", "ambiguous": "找到多个完全匹配的未付款订单", "unknown": "订单字段不完整、不一致或读取失败", } return PurchaseReconcileOutcome( "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 def _query( task: TaskDetail, run: TaskRunRecord ) -> PurchaseReconcileQuery: payload = task.admin_payload.get("payload") if not isinstance(payload, Mapping): raise ValueError("采购任务缺少 payload") options = payload.get("options") if not isinstance(options, Mapping) or not options: raise ValueError("采购任务缺少 options") goods_id = str(payload.get("goods_id") or "").strip() 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: raise ValueError("采购任务缺少 goods_id") if ( isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0 ): 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( goods_id=goods_id, options={str(key): str(value) for key, value in options.items()}, quantity=quantity, 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", }, }