Files
cmautobuy/client/src/purchase_reconcile_service.py
T

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",
},
}