From 598c8080932f01d5a2c266b8ae100dc2d52e02fd Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 10:42:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E9=87=87=E9=9B=86=E5=92=8C=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E9=87=87=E8=B4=AD=20(#138=20#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/AGENTS.md | 2 +- client/src/pdd_ui.py | 53 +++++--- client/src/pdd_ui_event.py | 196 ++++++++++++++++++++++++---- client/src/purchase_task_service.py | 12 ++ client/src/task_models.py | 21 ++- client/src/task_repository.py | 113 ++++++++++++++++ client/test/test_pdd_ui.py | 8 +- client/test/test_pdd_ui_event.py | 11 +- client/test/test_task_repository.py | 46 ++++++- docs/client/02-architecture.md | 2 +- docs/client/05-ui-specification.md | 15 +-- docs/client/06-quality-security.md | 1 + 12 files changed, 420 insertions(+), 60 deletions(-) diff --git a/client/AGENTS.md b/client/AGENTS.md index a2fd2d3..65a8c59 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -73,7 +73,7 @@ - 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。 - 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。 - 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。 -- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。 +- 会产生外部后果的命令只有“获取任务”“重新采集”“重新采购”和“重新上报”:前两者按勾选任务预检后在固定设备线程串行执行;重新采购必须永久拒绝历史上进入过不可逆阶段的任务。重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。 - 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。 - 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。 - 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。 diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 0aec71e..21cf2ac 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -390,7 +390,8 @@ class PDDTaskPage(QWidget): autoFetchRequested = pyqtSignal() searchRequested = pyqtSignal(dict) refreshRequested = pyqtSignal() - rerunRequested = pyqtSignal(str) + rerunRequested = pyqtSignal(object) + purchaseRerunRequested = pyqtSignal(object) rerunCancelRequested = pyqtSignal() resubmitRequested = pyqtSignal(object) removeRequested = pyqtSignal(object) @@ -460,10 +461,14 @@ class PDDTaskPage(QWidget): self.invertSelectionButton.setToolTip("反选当前已加载的任务") self.invertSelectionButton.setEnabled(False) - self.rerunButton = PushButton(FIF.UPDATE, "重新执行", self) - self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") + self.rerunButton = PushButton(FIF.UPDATE, "重新采集", self) + self.rerunButton.setAccessibleName("重新采集勾选的采集任务") self.rerunButton.setEnabled(False) + self.purchaseRerunButton = PushButton(FIF.SHOPPING_CART, "重新采购", self) + self.purchaseRerunButton.setAccessibleName("重新采购勾选的采购任务") + self.purchaseRerunButton.setEnabled(False) + self.resubmitButton = PushButton(FIF.SEND, "重新上报", self) self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果") self.resubmitButton.setEnabled(False) @@ -489,7 +494,7 @@ class PDDTaskPage(QWidget): if compact == self._compact_commands and self.commandLayout.count(): return self._compact_commands = compact - for column in range(16): + for column in range(17): self.commandLayout.setColumnStretch(column, 0) if compact: @@ -506,9 +511,10 @@ class PDDTaskPage(QWidget): (self.selectAllButton, 2, 5, 1, 1), (self.invertSelectionButton, 2, 6, 1, 1), (self.checkedCountLabel, 2, 7, 1, 1), - (self.rerunButton, 2, 8, 1, 1), - (self.resubmitButton, 2, 9, 1, 1), - (self.removeButton, 2, 10, 1, 1), + (self.resubmitButton, 2, 8, 1, 1), + (self.rerunButton, 2, 9, 1, 1), + (self.purchaseRerunButton, 2, 10, 1, 1), + (self.removeButton, 2, 11, 1, 1), ) self.commandLayout.setColumnStretch(4, 1) else: @@ -525,9 +531,10 @@ class PDDTaskPage(QWidget): (self.selectAllButton, 0, 10, 1, 1), (self.invertSelectionButton, 0, 11, 1, 1), (self.checkedCountLabel, 0, 12, 1, 1), - (self.rerunButton, 0, 13, 1, 1), - (self.resubmitButton, 0, 14, 1, 1), - (self.removeButton, 0, 15, 1, 1), + (self.resubmitButton, 0, 13, 1, 1), + (self.rerunButton, 0, 14, 1, 1), + (self.purchaseRerunButton, 0, 15, 1, 1), + (self.removeButton, 0, 16, 1, 1), ) self.commandLayout.setColumnStretch(6, 4) self.commandLayout.setColumnStretch(9, 1) @@ -619,6 +626,7 @@ class PDDTaskPage(QWidget): self.taskModel.invert_loaded_task_checks ) self.rerunButton.clicked.connect(self._request_rerun) + self.purchaseRerunButton.clicked.connect(self._request_purchase_rerun) self.resubmitButton.clicked.connect(self._request_resubmit) self.removeButton.clicked.connect(self._request_remove) self.clearFiltersButton.clicked.connect(self.clear_filters) @@ -636,7 +644,7 @@ class PDDTaskPage(QWidget): def resizeEvent(self, event) -> None: """按内容区宽度切换顶部命令区的单行或双行布局。""" - self._update_command_layout(compact=event.size().width() < 1460) + self._update_command_layout(compact=event.size().width() < 1580) super().resizeEvent(event) def set_tasks(self, tasks: Iterable[TaskRow]) -> None: @@ -750,8 +758,15 @@ class PDDTaskPage(QWidget): if self._rerun_state != "idle": return task_ids = self.taskModel.checked_task_ids() - if len(task_ids) == 1: - self.rerunRequested.emit(task_ids[0]) + if task_ids: + self.rerunRequested.emit(task_ids) + + def _request_purchase_rerun(self) -> None: + if self._rerun_state != "idle": + return + task_ids = self.taskModel.checked_task_ids() + if task_ids: + self.purchaseRerunRequested.emit(task_ids) def _request_resubmit(self) -> None: if self._resubmit_running or self._remove_running: @@ -773,6 +788,7 @@ class PDDTaskPage(QWidget): self.rerunButton.setAccessibleName("停止当前重新采集") self.rerunButton.setEnabled(True) self.resubmitButton.setEnabled(False) + self.purchaseRerunButton.setEnabled(False) self.removeButton.setEnabled(False) self.selectAllButton.setEnabled(False) self.invertSelectionButton.setEnabled(False) @@ -783,21 +799,26 @@ class PDDTaskPage(QWidget): self.rerunButton.setAccessibleName("正在停止重新采集") self.rerunButton.setEnabled(False) self.resubmitButton.setEnabled(False) + self.purchaseRerunButton.setEnabled(False) self.removeButton.setEnabled(False) self.selectAllButton.setEnabled(False) self.invertSelectionButton.setEnabled(False) self.autoFetchButton.setEnabled(False) return - self.rerunButton.setText("重新执行") - self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") + self.rerunButton.setText("重新采集") + self.rerunButton.setAccessibleName("重新采集勾选的采集任务") + self.purchaseRerunButton.setText("重新采购") idle = not self._resubmit_running and not self._remove_running has_tasks = self.taskModel.data_row_count() > 0 self.autoFetchButton.setEnabled(idle) self.selectAllButton.setEnabled(idle and has_tasks) self.invertSelectionButton.setEnabled(idle and has_tasks) self.rerunButton.setEnabled( - idle and len(self.taskModel.checked_task_ids()) == 1 + idle and bool(self.taskModel.checked_task_ids()) + ) + self.purchaseRerunButton.setEnabled( + idle and bool(self.taskModel.checked_task_ids()) ) self.resubmitButton.setEnabled( idle and bool(self.taskModel.checked_task_ids()) diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index afc1995..e015bfb 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -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( diff --git a/client/src/purchase_task_service.py b/client/src/purchase_task_service.py index 6a501d8..d15bdd3 100644 --- a/client/src/purchase_task_service.py +++ b/client/src/purchase_task_service.py @@ -549,12 +549,24 @@ class PurchaseTaskService: f"任务 {task_id} 采购演练未完成,失败信息已提交 Admin", task_id, ) + purchase = event.payload_json.get("purchase") + if isinstance(purchase, Mapping) and purchase.get("mode") == "live": + return PurchaseTaskOutcome( + "succeeded", + f"任务 {task_id} 已核对订单并提交 Admin", + task_id, + ) return PurchaseTaskOutcome( "succeeded", f"任务 {task_id} 演练完成并已提交 Admin;没有提交订单", task_id, ) + def submit_saved_event(self, event: OutboxEventRecord) -> PurchaseTaskOutcome: + """提交核单后已经落库的 Outbox,供批量采购闭环复用。""" + + return self._submit(event) + def _result_data( self, target: PurchaseTarget, state: PurchasePageState ) -> dict[str, object]: diff --git a/client/src/task_models.py b/client/src/task_models.py index 42dec99..24e3797 100644 --- a/client/src/task_models.py +++ b/client/src/task_models.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Mapping, Optional +from typing import Any, Dict, Mapping, Optional, Tuple class TaskType(str, Enum): @@ -104,6 +104,25 @@ class TaskFilters: keyword: str = "" +@dataclass(frozen=True) +class TaskRerunPlan: + """批量重新执行预检结果;界面只展示计数,不自行判断安全条件。""" + + target_type: TaskType + selected_count: int + eligible_task_ids: Tuple[str, ...] + filtered_task_ids: Tuple[str, ...] + blocked: Tuple[Tuple[str, str], ...] + + +@dataclass(frozen=True) +class TaskRerunBatch: + """交给固定设备线程串行处理的批量命令。""" + + target_type: TaskType + task_ids: Tuple[str, ...] + + @dataclass(frozen=True) class TaskSummary: """任务表格使用的轻量数据,不包含完整 JSON。""" diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 9a67489..720181a 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -20,6 +20,7 @@ from .task_models import ( StartedTaskRun, TaskDetail, TaskFilters, + TaskRerunPlan, TaskStatus, TaskSummary, TaskType, @@ -39,6 +40,10 @@ class CollectRerunError(ValueError): """当前任务不满足重新采集条件。""" +class PurchaseRerunError(ValueError): + """当前任务不满足安全重新采购条件。""" + + class TaskRemovalError(ValueError): """勾选任务不满足从普通列表移除的安全条件。""" @@ -472,6 +477,42 @@ class TaskRepository: connection.close() return self._to_detail(row) + def plan_rerun_batch( + self, remote_task_ids: Iterable[str], target_type: TaskType + ) -> TaskRerunPlan: + """批量预检并分类:可执行、类型不符、被安全条件阻止。""" + + stable_ids = tuple(dict.fromkeys(str(value) for value in remote_task_ids if value)) + eligible: List[str] = [] + filtered: List[str] = [] + blocked: List[Tuple[str, str]] = [] + connection = open_database(self._db_path) + try: + for task_id in stable_ids: + row = connection.execute( + "SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (task_id,) + ).fetchone() + if row is None: + blocked.append((task_id, "任务不存在")) + continue + if row["task_type"] != target_type.value: + filtered.append(task_id) + continue + try: + if target_type is TaskType.COLLECT: + self._check_collect_rerun(connection, row) + else: + self._check_purchase_rerun(connection, row) + except (CollectRerunError, PurchaseRerunError) as exc: + blocked.append((task_id, str(exc))) + else: + eligible.append(task_id) + finally: + connection.close() + return TaskRerunPlan( + target_type, len(stable_ids), tuple(eligible), tuple(filtered), tuple(blocked) + ) + def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail: """事务内把已结束的采集任务恢复为待执行,保留旧结果。""" @@ -508,6 +549,7 @@ class TaskRepository: if row["task_type"] != TaskType.COLLECT.value: raise CollectRerunError("采购任务不能重新执行,以免重复下单") allowed_statuses = { + TaskStatus.CLAIMED.value, TaskStatus.RETRY_WAIT.value, TaskStatus.SUCCEEDED.value, TaskStatus.FAILED.value, @@ -547,6 +589,77 @@ class TaskRepository: "任务仍有未发送的结果,请先勾选任务点击“重新上报”" ) + def validate_purchase_rerun(self, remote_task_id: str) -> TaskDetail: + """校验采购任务能否重新采购;历史上已下单的任务永久拒绝。""" + + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (remote_task_id,) + ).fetchone() + if row is None: + raise PurchaseRerunError(f"任务 {remote_task_id} 不存在") + self._check_purchase_rerun(connection, row) + finally: + connection.close() + return self._to_detail(row) + + def prepare_purchase_rerun(self, remote_task_id: str) -> TaskDetail: + """事务内恢复安全采购任务;不会清除任何历史执行记录。""" + + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + row = connection.execute( + "SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (remote_task_id,) + ).fetchone() + if row is None: + raise PurchaseRerunError(f"任务 {remote_task_id} 不存在") + self._check_purchase_rerun(connection, row) + connection.execute( + "UPDATE pdd_tasks SET status = 'claimed'," + " current_step = 'purchase_rerun_requested', finished_at = NULL," + " last_error_code = NULL, last_error_message = NULL, updated_at = ?" + " WHERE id = ?", + (now, row["id"]), + ) + finally: + connection.close() + task = self.get_task(remote_task_id) + assert task is not None + return task + + @staticmethod + def _check_purchase_rerun( + connection: sqlite3.Connection, row: sqlite3.Row + ) -> None: + if row["task_type"] != TaskType.PURCHASE.value: + raise PurchaseRerunError("采集任务不能重新采购") + irreversible = connection.execute( + "SELECT 1 FROM task_runs WHERE task_id = ?" + " AND irreversible_action_at IS NOT NULL LIMIT 1", + (row["id"],), + ).fetchone() + if irreversible is not None: + raise PurchaseRerunError("任务历史上已进入下单阶段,只准核对订单,绝不重新下单") + allowed = { + TaskStatus.CLAIMED.value, + TaskStatus.RETRY_WAIT.value, + TaskStatus.SUCCEEDED.value, + TaskStatus.FAILED.value, + TaskStatus.CANCELLED.value, + TaskStatus.MANUAL_REVIEW.value, + } + if row["status"] not in allowed: + raise PurchaseRerunError(f"任务当前状态 {row['status']} 不能重新采购") + unsent = connection.execute( + "SELECT 1 FROM outbox_events WHERE task_id = ? AND status != 'sent' LIMIT 1", + (row["id"],), + ).fetchone() + if unsent is not None: + raise PurchaseRerunError("任务仍有未发送数据,请先点击“重新上报”") + def start_collect_run( self, remote_task_id: str, device_address: str ) -> StartedTaskRun: diff --git a/client/test/test_pdd_ui.py b/client/test/test_pdd_ui.py index 25f69ef..d24a73c 100644 --- a/client/test/test_pdd_ui.py +++ b/client/test/test_pdd_ui.py @@ -94,6 +94,9 @@ class TaskTableModelCheckTests(unittest.TestCase): refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton)) rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton)) resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton)) + purchase_position = layout.getItemPosition( + layout.indexOf(page.purchaseRerunButton) + ) remove_position = layout.getItemPosition(layout.indexOf(page.removeButton)) select_all_position = layout.getItemPosition( layout.indexOf(page.selectAllButton) @@ -104,8 +107,9 @@ class TaskTableModelCheckTests(unittest.TestCase): self.assertEqual(refresh_position[1], search_position[1] + 1) self.assertEqual(search_position[0], 0) self.assertEqual(select_all_position[0], 0) - self.assertEqual(resubmit_position[1], rerun_position[1] + 1) - self.assertEqual(remove_position[1], resubmit_position[1] + 1) + self.assertEqual(rerun_position[1], resubmit_position[1] + 1) + self.assertEqual(purchase_position[1], rerun_position[1] + 1) + self.assertEqual(remove_position[1], purchase_position[1] + 1) self.assertEqual(invert_position[1], select_all_position[1] + 1) self.assertEqual(page.checkedCountLabel.text(), "已选 1 条") self.assertTrue(page.removeButton.isEnabled()) diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index fd7a9d8..e3cadfd 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -377,7 +377,7 @@ class PDDTaskPageEventTest(unittest.TestCase): self.assertEqual(row.price_cents, 3990) page.deleteLater() - def test_rerun_button_requires_exactly_one_checked_task(self): + def test_rerun_buttons_allow_multiple_checked_tasks(self): self._add_task(1) self._add_task(2) page = PDDTaskPage() @@ -385,16 +385,19 @@ class PDDTaskPageEventTest(unittest.TestCase): events.load_initial_tasks() self.assertFalse(page.rerunButton.isEnabled()) + self.assertFalse(page.purchaseRerunButton.isEnabled()) page.taskModel.setData( page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole ) self.app.processEvents() self.assertTrue(page.rerunButton.isEnabled()) + self.assertTrue(page.purchaseRerunButton.isEnabled()) page.taskModel.setData( page.taskModel.index(1, 0), Qt.Checked, Qt.CheckStateRole ) self.app.processEvents() - self.assertFalse(page.rerunButton.isEnabled()) + self.assertTrue(page.rerunButton.isEnabled()) + self.assertTrue(page.purchaseRerunButton.isEnabled()) events.shutdown() page.deleteLater() @@ -748,7 +751,7 @@ class PDDTaskPageEventTest(unittest.TestCase): page.rerunRequested.emit("PDD-001") message_box.return_value.cancelButton.setText.assert_called_once_with( - "暂不重新采集" + "取消" ) message_box.return_value.cancelButton.setFocus.assert_called_once_with() self.assertFalse(events._claim_busy) @@ -806,7 +809,7 @@ class PDDTaskPageEventTest(unittest.TestCase): "取消后工作线程没有按时结束", ) - self.assertEqual(page.rerunButton.text(), "重新执行") + self.assertEqual(page.rerunButton.text(), "重新采集") self.assertIn("停止请求已处理", page.statusLabel.text()) events.shutdown() page.deleteLater() diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index 03bb470..a2f6be3 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -429,8 +429,10 @@ class TaskRepositoryTests(unittest.TestCase): self.repository.validate_collect_rerun("PURCHASE-RERUN") self.repository.add_claimed_task(self._task("ACTIVE-RERUN")) - with self.assertRaisesRegex(CollectRerunError, "待执行"): - self.repository.validate_collect_rerun("ACTIVE-RERUN") + self.assertEqual( + self.repository.validate_collect_rerun("ACTIVE-RERUN").status, + TaskStatus.CLAIMED, + ) self.repository.add_claimed_task(self._task("UNSENT-RERUN")) started = self.repository.start_collect_run("UNSENT-RERUN", "USB-001") @@ -449,6 +451,46 @@ class TaskRepositoryTests(unittest.TestCase): with self.assertRaisesRegex(CollectRerunError, "未发送"): self.repository.validate_collect_rerun("UNSENT-RERUN") + def test_batch_rerun_plan_filters_other_task_type(self): + self.repository.add_claimed_task(self._task("COLLECT-PLAN")) + self.repository.add_claimed_task( + self._task("PURCHASE-PLAN", TaskType.PURCHASE) + ) + + plan = self.repository.plan_rerun_batch( + ("COLLECT-PLAN", "PURCHASE-PLAN"), TaskType.COLLECT + ) + + self.assertEqual(plan.eligible_task_ids, ("COLLECT-PLAN",)) + self.assertEqual(plan.filtered_task_ids, ("PURCHASE-PLAN",)) + self.assertEqual(plan.blocked, ()) + + def test_purchase_rerun_permanently_rejects_irreversible_history(self): + self.repository.add_claimed_task( + self._task("PURCHASE-SAFE", TaskType.PURCHASE) + ) + started = self.repository.start_purchase_run("PURCHASE-SAFE", "USB-001") + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE task_runs SET irreversible_action_at = ? WHERE attempt_id = ?", + ("2026-08-11T00:00:00Z", started.attempt_id), + ) + connection.execute( + "UPDATE pdd_tasks SET status = 'manual_review' WHERE remote_task_id = ?", + ("PURCHASE-SAFE",), + ) + finally: + connection.close() + + plan = self.repository.plan_rerun_batch( + ("PURCHASE-SAFE",), TaskType.PURCHASE + ) + + self.assertEqual(plan.eligible_task_ids, ()) + self.assertIn("只准核对订单", plan.blocked[0][1]) + def test_retry_wait_can_rerun_only_after_failure_outbox_is_sent(self): self.repository.add_claimed_task(self._task("RETRY-RERUN")) first = self.repository.start_collect_run("RETRY-RERUN", "USB-001") diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index e90b57b..bb2b817 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -159,7 +159,7 @@ Qt 主线程 持久设备工作线程(单设备、固定 QThread) ├── uiautomator2 Device 的创建、健康检查、调用和释放 ├── 页面等待与 XML 解析 -└── 采集、采购、订单核对和手动重新执行的串行命令 +└── 采集、采购、订单核对和手动批量重新执行的串行命令 结果提交工作线程或同一任务线程的独立队列 └── Outbox 重试,不重复执行 PDD 操作 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 7539df4..87ea382 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -9,7 +9,7 @@ ## 1. 设计目标 - 让操作人员在一个页面完成任务监控、搜索和异常定位。 -- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。 +- “获取任务”“重新采集”“重新采购”和“重新上报”会产生外部后果;重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。 - 长任务状态始终可找到,不使用连续模态弹窗打断工作。 - 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。 - 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。 @@ -35,7 +35,7 @@ ```text ┌─────────────────────────────────────────────────────────────┐ │ PDD 任务 │ -│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新执行] [重新上报] [删除] │ +│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新上报] [重新采集] [重新采购] [删除] │ ├─────────────────────────────────────────────────────────────┤ │ 选择 │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │ │ │ @@ -86,19 +86,18 @@ ### 4.3 刷新、全选、反选与批量操作 - “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。 -- 页面右侧显示“全选”“反选”和“已选 N 条”,之后依次是“重新执行”“重新上报”“删除”。 +- 页面右侧显示“全选”“反选”和“已选 N 条”,之后依次是“重新上报”“重新采集”“重新采购”“删除”。 - “全选”和“反选”只作用于当前筛选条件下已经加载的真实任务行,空白占位行不参与。全选后新领取或继续增量加载的任务默认不勾选,防止批量操作范围在用户不知情时扩大。 - 没有真实任务,或正在重新执行、重新上报、删除时,禁用“全选”和“反选”。 -- “重新执行”只在恰好勾选一条任务时启用;多选不允许批量操作手机。 -- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。 +- 勾选一条或多条任务后可以点击“重新采集”或“重新采购”。点击后先批量预检,再显示可执行、类型过滤和安全阻止数量;“取消”默认聚焦,点击、按 `Escape` 或关闭弹窗都不得启动任务。 - 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。 -- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 +- 重新采集只处理采集任务,重新采购只处理采购任务,另一类型计入过滤数量。执行中、结果待提交、仍有未发送 Outbox、已经排队或自动获取忙碌时必须阻止,并用中文说明原因。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 - 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。 - 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。 - 重新采集的前置校验警告和后台错误 `InfoBar` 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。 - 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。 -- 采购任务始终不能通过“重新执行”入口启动;需要处理时由自动获取的安全恢复流程决定。 +- 重新采购逐条串行执行。任一任务未形成“执行、核单、上报”成功闭环时立即停止剩余队列。历史上任何一次执行已有 `irreversible_action_at` 的任务永久禁止重新采购,只能只读核单。 - “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示 “重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。 - “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。 @@ -411,7 +410,7 @@ self.show_recoverable_error( ## 10. 键盘与无障碍 -- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 全选 → 反选 → 重新执行 → 重新上报 → 删除 → 表格 → 状态区可操作项。 +- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 全选 → 反选 → 重新上报 → 重新采集 → 重新采购 → 删除 → 表格 → 状态区可操作项。 - `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。 - 仅图标按钮必须设置准确的无障碍名称和工具提示。 - 表单具有可见标签,占位符不能替代标签。 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index e8462e2..af9df65 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -77,6 +77,7 @@ Admin 新建采购任务固定为真实下单(不支付),Client 不再提 3. 商品编号、标题、规格、数量、库存和价格保护校验通过。 4. 最终下单前后均有持久化步骤标记。 5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。 +6. 批量重新采购永久拒绝历史任一次执行带不可逆标记的任务;当前任务核单不确定时不启动下一条。 6. 订单匹配能识别唯一候选;多个候选进入人工处理。 7. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。 8. 真实设备首单按独立验收工单执行,并明确停止在支付前。