feat: 增加批量重新采集和安全重新采购 (#138 #139)

This commit is contained in:
chengma
2026-08-11 10:42:51 +08:00
parent 7aa49c0e29
commit 598c808093
12 changed files with 420 additions and 60 deletions
+171 -25
View File
@@ -50,21 +50,29 @@ from .pdd_device_service import (
PersistentPddDeviceService,
bind_thread_device_service,
)
from .purchase_task_service import PurchaseAdapterFactory
from .purchase_task_service import LivePurchaseAdapterFactory
from .purchase_reconcile_service import PurchaseReconcileFactory
from .purchase_task_service import (
LivePurchaseAdapterFactory,
PurchaseAdapterFactory,
PurchaseTaskService,
)
from .purchase_reconcile_service import (
PurchaseReconcileFactory,
PurchaseReconcileService,
)
from .selected_android_device_service import SelectedAndroidDeviceService
from .settings_repository import SettingsRepository
from .task_models import (
OutboxEventType,
OutboxStatus,
TaskFilters,
TaskRerunBatch,
TaskStatus,
TaskSummary,
TaskType,
)
from .task_repository import (
CollectRerunError,
PurchaseRerunError,
TaskRemovalError,
TaskRepository,
)
@@ -185,8 +193,8 @@ class ClaimTaskWorker(QObject):
self.run_selected(self._selected_task_id)
@pyqtSlot(str)
def run_selected(self, selected_task_id: str = "") -> None:
@pyqtSlot(object)
def run_selected(self, command="") -> None:
"""执行一条命令;该槽始终由固定设备 QThread 串行调用。"""
if not self._accepting:
@@ -196,7 +204,7 @@ class ClaimTaskWorker(QObject):
assert self._idle_timer is not None
self._idle_timer.stop()
try:
if self._cancelled and not selected_task_id:
if self._cancelled and not command:
return
with bind_thread_device_service(self._device_service):
client_settings = self._client_service.load()
@@ -209,6 +217,13 @@ class ClaimTaskWorker(QObject):
client_settings.client_id,
client_settings.client_name,
)
if isinstance(command, TaskRerunBatch):
self._device_connection_checker(android_serial or "")
self._execute_rerun_batch(
command, client, android_serial or ""
)
return
selected_task_id = str(command or "")
if selected_task_id:
self._device_connection_checker(android_serial or "")
self._task_repository.prepare_collect_rerun(
@@ -276,6 +291,90 @@ class ClaimTaskWorker(QObject):
)
self.completed.emit()
def _execute_rerun_batch(
self, command: TaskRerunBatch, client: ClientInfo, android_serial: str
) -> None:
"""在同一设备线程中逐条执行;采购不确定时停止剩余队列。"""
for index, task_id in enumerate(command.task_ids):
if self._cancelled:
self.outcome.emit("cancelled", f"任务 {task_id} 尚未开始", task_id)
continue
try:
if command.target_type is TaskType.COLLECT:
self._task_repository.prepare_collect_rerun(task_id)
service = CollectTaskService(
self._gateway,
self._task_repository,
client,
android_serial,
cancelled=lambda: self._cancelled,
collect_service_factory=self._collect_service_factory,
)
result = service.execute_selected(task_id)
else:
result = self._execute_purchase_rerun(task_id, client, android_serial)
except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
self.outcome.emit("skipped", str(exc), task_id)
continue
self.outcome.emit(result.kind, result.message, result.task_id or task_id)
if command.target_type is TaskType.PURCHASE and result.kind != "succeeded":
for remaining_id in command.task_ids[index + 1 :]:
self.outcome.emit(
"skipped",
f"前一采购任务未形成成功闭环,任务 {remaining_id} 未开始",
remaining_id,
)
break
def _execute_purchase_rerun(
self, task_id: str, client: ClientInfo, android_serial: str
):
"""执行一次安全采购,并在真实下单后完成核单和 Admin 上报。"""
task = self._task_repository.prepare_purchase_rerun(task_id)
factory = (
self._live_purchase_adapter_factory
if task.execution_mode == "live"
else self._purchase_adapter_factory
)
if factory is None:
raise ValueError("采购执行器未就绪")
service = PurchaseTaskService(
self._gateway,
self._task_repository,
client,
android_serial,
factory,
cancelled=lambda: self._cancelled,
)
result = service.execute_selected(task_id)
if result.kind != "reconcile_pending":
return result
# 已进入不可逆阶段后,即使用户停止,也必须完成当前任务的只读核单。
remaining_ms = PURCHASE_RECONCILE_DELAY_MS
while remaining_ms > 0:
step_ms = min(250, remaining_ms)
QThread.msleep(step_ms)
remaining_ms -= step_ms
if self._purchase_reconcile_factory is None:
return type(result)(
"manual_review", "采购核单执行器未就绪;绝不重新下单", task_id
)
reconcile = PurchaseReconcileService(
self._task_repository,
android_serial,
self._purchase_reconcile_factory,
cancelled=lambda: False,
).execute_selected(task_id)
if reconcile.kind != "result_pending":
return type(result)(reconcile.kind, reconcile.message, task_id)
event = self._task_repository.outbox_for_resubmit(task_id)
if event is None:
return type(result)("manual_review", "核单结果未写入待上报队列", task_id)
return service.submit_saved_event(event)
@pyqtSlot()
def release_device(self) -> None:
"""停止、换设备或配置变化后,在设备线程安全释放缓存。"""
@@ -419,7 +518,7 @@ class TaskRemoveWorker(QObject):
class PDDTaskPageEvent(QObject):
"""把 PDD 页面只读操作连接到本地任务 Repository。"""
_claimRunRequested = pyqtSignal(str)
_claimRunRequested = pyqtSignal(object)
_claimReleaseRequested = pyqtSignal()
_claimShutdownRequested = pyqtSignal()
_claimGatewayChanged = pyqtSignal(object)
@@ -452,6 +551,11 @@ class PDDTaskPageEvent(QObject):
self._claim_worker: Optional[ClaimTaskWorker] = None
self._claim_operation = ""
self._rerun_cancel_requested = False
self._rerun_target_type = TaskType.COLLECT
self._rerun_total = 0
self._rerun_succeeded = 0
self._rerun_failed = 0
self._rerun_skipped = 0
self._rerun_feedback: Optional[InfoBar] = None
self._claim_feedback: Optional[InfoBar] = None
self._device_feedback: Optional[InfoBar] = None
@@ -517,6 +621,7 @@ class PDDTaskPageEvent(QObject):
page.searchRequested.connect(self.search_tasks)
page.refreshRequested.connect(self.refresh_tasks)
page.rerunRequested.connect(self.request_rerun)
page.purchaseRerunRequested.connect(self.request_purchase_rerun)
page.rerunCancelRequested.connect(self.request_cancel_rerun)
page.resubmitRequested.connect(self.request_resubmit)
page.removeRequested.connect(self.request_remove)
@@ -574,11 +679,23 @@ class PDDTaskPageEvent(QObject):
self._reload()
@pyqtSlot(str)
def request_rerun(self, task_id: str) -> None:
"""确认后在后台重新采集当前选中的一条终态任务。"""
@pyqtSlot(object)
def request_rerun(self, task_ids) -> None:
"""预检并确认批量重新采集。"""
if self._closing or not task_id:
self._request_rerun_batch(task_ids, TaskType.COLLECT)
@pyqtSlot(object)
def request_purchase_rerun(self, task_ids) -> None:
"""预检并确认带不可逆门禁的批量重新采购。"""
self._request_rerun_batch(task_ids, TaskType.PURCHASE)
def _request_rerun_batch(self, task_ids, target_type: TaskType) -> None:
values = (task_ids,) if isinstance(task_ids, str) else task_ids
stable_ids = tuple(dict.fromkeys(str(value) for value in values if value))
if self._closing or not stable_ids:
return
if (
self._auto_fetch_running
@@ -616,8 +733,8 @@ class PDDTaskPageEvent(QObject):
)
return
try:
detail = self._repository.validate_collect_rerun(task_id)
except (CollectRerunError, ValueError) as exc:
plan = self._repository.plan_rerun_batch(stable_ids, target_type)
except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
self._show_rerun_warning("不能重新执行", str(exc))
return
except Exception:
@@ -626,20 +743,32 @@ class PDDTaskPageEvent(QObject):
)
return
title = detail.title or "尚未获取标题"
action = "采集" if target_type is TaskType.COLLECT else "采购"
if not plan.eligible_task_ids:
self._show_rerun_warning(
f"没有可重新{action}的任务",
f"已选 {plan.selected_count} 条,过滤 {len(plan.filtered_task_ids)} 条其他类型,"
f"安全条件阻止 {len(plan.blocked)} 条。",
)
return
dialog = MessageBox(
"确认重新采集",
f"任务:{detail.remote_task_id}\n商品:{title}\n\n"
"新结果会覆盖 Client 和 Admin 的当前采集数据,旧结果仍保留在执行记录中。",
f"确认重新{action} {len(plan.eligible_task_ids)} 条任务?",
f"已选 {plan.selected_count} 条;可执行 {len(plan.eligible_task_ids)} 条;"
f"过滤其他类型 {len(plan.filtered_task_ids)} 条;安全条件阻止 {len(plan.blocked)} 条。\n\n"
+ ("采购任务会逐条执行;核单不确定时立即停止剩余任务,绝不重新下单。"
if target_type is TaskType.PURCHASE else
"新结果会覆盖 Client 和 Admin 当前采集数据,旧执行记录仍保留。"),
self._page.window(),
)
dialog.yesButton.setText("重新采集")
dialog.cancelButton.setText("暂不重新采集")
dialog.yesButton.setText(f"重新{action}")
dialog.cancelButton.setText("取消")
dialog.cancelButton.setFocus()
if not dialog.exec():
return
self._start_rerun_worker(task_id)
self._start_rerun_worker(
TaskRerunBatch(target_type, plan.eligible_task_ids)
)
@pyqtSlot(object)
def request_resubmit(self, task_ids) -> None:
@@ -833,7 +962,7 @@ class PDDTaskPageEvent(QObject):
if not self._closing:
self._page.set_resubmit_running(False)
def _start_rerun_worker(self, task_id: str) -> None:
def _start_rerun_worker(self, command: TaskRerunBatch) -> None:
"""把指定任务提交到持久设备工作线程。"""
assert self._claim_gateway is not None
@@ -841,17 +970,21 @@ class PDDTaskPageEvent(QObject):
self._close_claim_feedback()
self._close_device_feedback()
self._rerun_cancel_requested = False
self._rerun_target_type = command.target_type
self._rerun_total = len(command.task_ids)
self._rerun_succeeded = self._rerun_failed = self._rerun_skipped = 0
self._claim_busy = True
self._page.set_rerun_running(True)
self._page.set_engine_status(
f"正在检查 Android 设备并准备重新采集任务 {task_id}…"
f"正在检查 Android 设备并准备重新{'采集' if command.target_type is TaskType.COLLECT else '采购'}"
f" {len(command.task_ids)} 条任务…"
)
self._reload()
worker = self._ensure_claim_executor()
self._claim_operation = "rerun"
worker.prepare_run()
self._claimRunRequested.emit(task_id)
self._claimRunRequested.emit(command)
@pyqtSlot()
def request_cancel_rerun(self) -> None:
@@ -884,9 +1017,13 @@ class PDDTaskPageEvent(QObject):
"停止请求已处理,请查看任务最新状态"
)
return
if kind == "succeeded":
self._rerun_succeeded += 1
elif kind in {"skipped", "cancelled"}:
self._rerun_skipped += 1
else:
self._rerun_failed += 1
self._page.set_engine_status(message)
if kind != "succeeded":
self._show_rerun_error("重新采集需要处理", message)
@pyqtSlot(str)
def _on_rerun_failed(self, message: str) -> None:
@@ -919,6 +1056,15 @@ class PDDTaskPageEvent(QObject):
self._page.set_engine_status(
"停止请求已处理,请查看任务最新状态"
)
elif self._rerun_succeeded + self._rerun_failed + self._rerun_skipped:
action = "采集" if self._rerun_target_type is TaskType.COLLECT else "采购"
summary = (
f"批量重新{action}完成:成功 {self._rerun_succeeded} 条,"
f"失败 {self._rerun_failed} 条,跳过 {self._rerun_skipped} 条"
)
self._page.set_engine_status(summary)
if self._rerun_failed or self._rerun_skipped:
self._show_rerun_warning(f"重新{action}未全部完成", summary)
def _show_rerun_warning(self, title: str, content: str) -> None:
bar = InfoBar.warning(