334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""不可逆阶段后的只读采购订单核对与结果入队。"""
|
|
|
|
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
|
|
)
|
|
read_failed = bool(diagnostics.get("error_code"))
|
|
match_status = (
|
|
"unknown" if read_failed or candidates else "not_found"
|
|
)
|
|
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:
|
|
error_code = str(
|
|
getattr(exc, "code", "RECONCILE_READ_FAILED")
|
|
or "RECONCILE_READ_FAILED"
|
|
)
|
|
scan = PurchaseReconcileScan(
|
|
diagnostics={
|
|
"error_code": error_code,
|
|
"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": "订单字段不完整、不一致或读取失败",
|
|
}
|
|
message = messages[match_status]
|
|
if match_status == "unknown" and diagnostics.get("error_message"):
|
|
message = f"订单读取失败:{str(diagnostics['error_message'])[:200]}"
|
|
return PurchaseReconcileOutcome(
|
|
"manual_review",
|
|
f"任务 {task.remote_task_id} {message};需人工处理,绝不重新下单",
|
|
task.remote_task_id,
|
|
)
|
|
|
|
@staticmethod
|
|
def _candidate_matches(
|
|
candidate: PurchaseOrderCandidate,
|
|
query: PurchaseReconcileQuery,
|
|
) -> bool:
|
|
if (
|
|
not candidate.order_no.strip()
|
|
or candidate.payment_status != "unpaid"
|
|
):
|
|
return False
|
|
try:
|
|
ordered_at = _parse_iso_time(candidate.ordered_at)
|
|
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
|
|
|
|
@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")
|
|
requested_options = {
|
|
str(key): str(value) for key, value in options.items()
|
|
}
|
|
goods_id = str(payload.get("goods_id") or "").strip()
|
|
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(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)
|
|
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("采购运行缺少有效确认单价")
|
|
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=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(),
|
|
)
|
|
|
|
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(query.options),
|
|
"quantity": query.quantity,
|
|
"unit_price_cent": query.unit_price_cent,
|
|
"total_price_cent": query.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",
|
|
},
|
|
}
|