diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index 22a427d..f238f0f 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -608,7 +608,7 @@ class PDDTaskPageEvent(QObject): self._stop_after_current(content) @pyqtSlot(str, str, str) - def _on_collect_outcome(self, kind: str, message: str, _task_id: str) -> None: + def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None: if self._closing: return self._reload() @@ -622,6 +622,13 @@ class PDDTaskPageEvent(QObject): ) elif kind == "result_pending": self._on_claim_retryable_failed(message) + elif kind == "failed" and self._is_retry_wait_task(task_id): + content = ( + f"自动获取:已停止 · 任务 {task_id} 重试已暂停;" + "请选择该任务点击“重新执行”,或重新启动获取任务" + ) + self._show_retry_paused(content) + self._stop_after_current(content) elif kind in {"manual_review", "failed"}: self._show_claim_error("采集任务需要处理", message) self._stop_after_current(message) @@ -632,6 +639,29 @@ class PDDTaskPageEvent(QObject): self._show_claim_error("自动获取已停止", content) self._stop_after_current(content) + def _is_retry_wait_task(self, task_id: str) -> bool: + """判断失败结果对应的任务是否仍可在本地重试。""" + + if not task_id: + return False + try: + task = self._repository.get_task(task_id) + except Exception: + return False + return task is not None and task.status is TaskStatus.RETRY_WAIT + + def _show_retry_paused(self, content: str) -> None: + """用持久警告说明任务不会自行倒计时重试。""" + + InfoBar.warning( + title="重试已暂停", + content=content, + isClosable=True, + duration=-1, + position=InfoBarPosition.TOP_RIGHT, + parent=self._page, + ) + def _show_claim_error(self, title: str, content: str) -> None: """显示不会自动消失的可恢复错误,同时保留底部状态文字。""" diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 3448ce8..e0ae4a7 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -262,6 +262,7 @@ class TaskRepository: if row["task_type"] != TaskType.COLLECT.value: raise CollectRerunError("采购任务不能重新执行,以免重复下单") allowed_statuses = { + TaskStatus.RETRY_WAIT.value, TaskStatus.SUCCEEDED.value, TaskStatus.FAILED.value, TaskStatus.CANCELLED.value, @@ -272,17 +273,20 @@ class TaskRepository: TaskStatus.CLAIMED.value: "待执行", TaskStatus.RUNNING.value: "执行中", TaskStatus.RESULT_PENDING.value: "结果待提交", - TaskStatus.RETRY_WAIT.value: "等待重试", }.get(row["status"], row["status"]) raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集") - unsent_count = int( - connection.execute( - "SELECT COUNT(*) FROM outbox_events" - " WHERE task_id = ? AND status != 'sent'", - (row["id"],), - ).fetchone()[0] - ) - if unsent_count: + unsent = connection.execute( + "SELECT status, last_error FROM outbox_events" + " WHERE task_id = ? AND status != 'sent'" + " ORDER BY id DESC LIMIT 1", + (row["id"],), + ).fetchone() + if unsent is not None and unsent["status"] == OutboxStatus.FAILED.value: + reason = unsent["last_error"] or "Admin 未接收上次结果" + raise CollectRerunError( + f"任务上次结果提交失败:{reason};请先处理后再重新采集" + ) + if unsent is not None: raise CollectRerunError("任务仍有未发送的结果,请先完成提交") def start_collect_run( diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 5c076de..bc9faa5 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -563,6 +563,65 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_retry_wait_failure_shows_paused_recovery_actions(self): + self._add_task(1) + started = self.repository.start_collect_run("PDD-001", "USB-001") + event = self.repository.save_collect_failure( + "PDD-001", + started.attempt_id, + TaskStatus.RETRY_WAIT, + "DEVICE_OFFLINE", + "设备离线", + True, + ) + self.repository.mark_outbox_sent(event.id) + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=RecordingClaimGateway(None), + settings_repository=self._saved_settings(), + ) + + events._on_collect_outcome( + "failed", + "任务 PDD-001 采集未完成", + "PDD-001", + ) + + self.assertIn("重试已暂停", events._stop_status) + self.assertIn("重新执行", events._stop_status) + self.assertIn("重新启动获取任务", events._stop_status) + events.shutdown() + page.deleteLater() + + def test_failed_task_does_not_show_retry_paused_status(self): + self._add_task(1) + started = self.repository.start_collect_run("PDD-001", "USB-001") + event = self.repository.save_collect_failure( + "PDD-001", + started.attempt_id, + TaskStatus.FAILED, + "GOODS_OFF_SHELF", + "商品已下架", + False, + ) + self.repository.mark_outbox_sent(event.id) + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=RecordingClaimGateway(None), + settings_repository=self._saved_settings(), + ) + + events._on_collect_outcome("failed", "商品已下架", "PDD-001") + + self.assertEqual(events._stop_status, "商品已下架") + self.assertNotIn("重试已暂停", events._stop_status) + events.shutdown() + page.deleteLater() + def test_duplicate_task_is_normal_status(self): task = collect_admin_task() self.repository.add_claimed_task(admin_task_to_new_claimed_task(task)) diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index 448fa74..0c5a6e9 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -267,6 +267,48 @@ class TaskRepositoryTests(unittest.TestCase): with self.assertRaisesRegex(CollectRerunError, "未发送"): self.repository.validate_collect_rerun("UNSENT-RERUN") + 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") + event = self.repository.save_collect_failure( + "RETRY-RERUN", + first.attempt_id, + TaskStatus.RETRY_WAIT, + "DEVICE_OFFLINE", + "设备离线", + True, + ) + + with self.assertRaisesRegex(CollectRerunError, "未发送"): + self.repository.validate_collect_rerun("RETRY-RERUN") + + self.repository.mark_outbox_sent(event.id) + prepared = self.repository.prepare_collect_rerun("RETRY-RERUN") + second = self.repository.start_collect_run("RETRY-RERUN", "USB-001") + + self.assertEqual(prepared.status, TaskStatus.CLAIMED) + self.assertEqual(second.attempt_no, 2) + + def test_retry_wait_explains_permanently_failed_outbox(self): + self.repository.add_claimed_task(self._task("RETRY-REJECTED")) + started = self.repository.start_collect_run( + "RETRY-REJECTED", "USB-001" + ) + event = self.repository.save_collect_failure( + "RETRY-REJECTED", + started.attempt_id, + TaskStatus.RETRY_WAIT, + "DEVICE_OFFLINE", + "设备离线", + True, + ) + self.repository.mark_outbox_failed(event.id, "任务不存在") + + with self.assertRaisesRegex( + CollectRerunError, "上次结果提交失败:任务不存在" + ): + self.repository.validate_collect_rerun("RETRY-REJECTED") + def test_recovery_restores_sending_and_interrupted_running(self): self.repository.add_claimed_task(self._task("TASK-RECOVER")) started = self.repository.start_collect_run("TASK-RECOVER", "USB-001") diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index 94911b7..53d28d5 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -425,13 +425,15 @@ CREATE TABLE app_settings ( | `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 | | `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 | | `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 | -| `retry_wait` 重试等待 | `running` | 退避时间到,**本地直接重跑**,新建 `task_runs` 记录(`attempt_no` 加 1) | 任务协调器 | +| `retry_wait` 重试等待 | `running` | 重新启动获取任务,或用户确认“重新执行”;**本地直接重跑**并新建 `task_runs` 记录 | 任务协调器 / 人 | | `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 | | `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 | | `manual_review` / `succeeded` / `failed` / `cancelled` | `claimed` | 用户在 Client 明确确认重新采集;仅限采集任务,且没有未发送 Outbox | 人 | **注意 `retry_wait` → `running` 是纯本地操作。** 任务已经在本地库里了,重试直接重跑就行, **不需要再向 Admin 要一次**。没有租约,也就没有"重新获取执行权"这回事。 +当前 `retry_wait` 不包含倒计时。自动获取因可恢复采集错误停止后,界面必须显示 +“重试已暂停”;用户可以选择任务点击“重新执行”,或重新启动获取任务。 ### 7.2 补充规则 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 5c4072b..f601060 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -82,10 +82,12 @@ - “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。 - “重新执行”位于刷新右侧;没有选中任务时禁用。 - 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。 -- 只允许重新采集已经结束的采集任务。采购、执行中、结果待提交、等待重试、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 +- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 - 重新采集在工作线程运行。执行期间禁用“获取任务”和“重新执行”,完成后刷新列表。 - 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。 +- “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示 + “重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。 ## 5. 任务表格