feat: 完善真实采购订单核对与恢复 (#100)
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
"""不可逆阶段中断后的只读采购结果核对。"""
|
||||
"""不可逆阶段后的只读采购订单核对与结果入队。"""
|
||||
|
||||
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 (
|
||||
PddPurchaseReconcileAdapter,
|
||||
PurchaseReconcileObservation,
|
||||
PurchaseOrderCandidate,
|
||||
PurchaseReconcileQuery,
|
||||
PurchaseReconcileScan,
|
||||
)
|
||||
from .task_models import TaskDetail
|
||||
from .task_models import TaskDetail, TaskRunRecord
|
||||
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)
|
||||
class PurchaseReconcileOutcome:
|
||||
"""只读核对的简短结果。"""
|
||||
@@ -27,7 +45,11 @@ class PurchaseReconcileOutcome:
|
||||
|
||||
|
||||
class PurchaseReconcileService:
|
||||
"""只读核对一条已进入不可逆阶段的采购运行。"""
|
||||
"""严格匹配未付款订单;唯一匹配才生成采购成功 Outbox。"""
|
||||
|
||||
_DIAGNOSTIC_KEYS = frozenset(
|
||||
{"pages_scanned", "candidate_count", "error_code", "error_message"}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,7 +67,7 @@ class PurchaseReconcileService:
|
||||
def execute_selected(
|
||||
self, remote_task_id: str
|
||||
) -> PurchaseReconcileOutcome:
|
||||
"""执行一次只读核对,任何结果都交给人工最终确认。"""
|
||||
"""只读核对一次;唯一未付款订单入 Outbox,其余转人工。"""
|
||||
|
||||
if not self._device_address:
|
||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||
@@ -60,17 +82,82 @@ class PurchaseReconcileService:
|
||||
if run.irreversible_action_at is None:
|
||||
raise ValueError("未进入不可逆阶段,不应启动订单核对")
|
||||
|
||||
query = self._query(task, run.irreversible_action_at)
|
||||
adapter = None
|
||||
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)
|
||||
observation = adapter.read_order_match(query)
|
||||
if not isinstance(observation, PurchaseReconcileObservation):
|
||||
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:
|
||||
observation = PurchaseReconcileObservation(
|
||||
"unknown", diagnostics={"error": str(exc)}
|
||||
scan = PurchaseReconcileScan(
|
||||
diagnostics={
|
||||
"error_code": "RECONCILE_READ_FAILED",
|
||||
"error_message": str(exc)[:300],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if adapter is not None:
|
||||
@@ -78,43 +165,63 @@ class PurchaseReconcileService:
|
||||
adapter.close()
|
||||
except Exception as exc:
|
||||
close_error = str(exc)
|
||||
return scan, close_error
|
||||
|
||||
diagnostics = dict(observation.diagnostics)
|
||||
diagnostics.update(
|
||||
{
|
||||
"order_no": observation.order_no,
|
||||
"ordered_at": observation.ordered_at,
|
||||
"mode": "reconcile_only",
|
||||
}
|
||||
)
|
||||
if close_error:
|
||||
diagnostics["close_error"] = close_error
|
||||
def _save_manual_review(
|
||||
self,
|
||||
task: TaskDetail,
|
||||
run: TaskRunRecord,
|
||||
match_status: str,
|
||||
diagnostics: Mapping[str, object],
|
||||
) -> PurchaseReconcileOutcome:
|
||||
self._repository.save_purchase_reconciliation(
|
||||
remote_task_id,
|
||||
task.remote_task_id,
|
||||
run.attempt_id,
|
||||
observation.match_status,
|
||||
diagnostics,
|
||||
match_status,
|
||||
dict(diagnostics),
|
||||
)
|
||||
if observation.match_status == "matched":
|
||||
message = (
|
||||
f"任务 {remote_task_id} 仅核对到唯一候选订单;"
|
||||
"请人工确认,程序不会重新下单"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"任务 {remote_task_id} 核对结果不确定;"
|
||||
"需人工处理,程序不会重新下单"
|
||||
)
|
||||
messages = {
|
||||
"not_found": "未找到符合时间范围的订单",
|
||||
"ambiguous": "找到多个完全匹配的未付款订单",
|
||||
"unknown": "订单字段不完整、不一致或读取失败",
|
||||
}
|
||||
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
|
||||
def _query(
|
||||
task: TaskDetail, irreversible_action_at: str
|
||||
task: TaskDetail, run: TaskRunRecord
|
||||
) -> PurchaseReconcileQuery:
|
||||
payload_root = task.admin_payload
|
||||
payload = payload_root.get("payload")
|
||||
payload = task.admin_payload.get("payload")
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("采购任务缺少 payload")
|
||||
options = payload.get("options")
|
||||
@@ -122,6 +229,11 @@ class PurchaseReconcileService:
|
||||
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 (
|
||||
@@ -130,9 +242,63 @@ class PurchaseReconcileService:
|
||||
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(k): str(v) for k, v in options.items()},
|
||||
options={str(key): str(value) for key, value in options.items()},
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user