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
+1 -1
View File
@@ -73,7 +73,7 @@
- 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。 - 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。
- 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。 - 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。
- 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。 - 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。
- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。 - 会产生外部后果的命令只有“获取任务”“重新采集”“重新采购”和“重新上报”:前两者按勾选任务预检后在固定设备线程串行执行;重新采购必须永久拒绝历史上进入过不可逆阶段的任务。重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。
- 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。 - 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。
- 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。 - 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。
- 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。 - 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。
+37 -16
View File
@@ -390,7 +390,8 @@ class PDDTaskPage(QWidget):
autoFetchRequested = pyqtSignal() autoFetchRequested = pyqtSignal()
searchRequested = pyqtSignal(dict) searchRequested = pyqtSignal(dict)
refreshRequested = pyqtSignal() refreshRequested = pyqtSignal()
rerunRequested = pyqtSignal(str) rerunRequested = pyqtSignal(object)
purchaseRerunRequested = pyqtSignal(object)
rerunCancelRequested = pyqtSignal() rerunCancelRequested = pyqtSignal()
resubmitRequested = pyqtSignal(object) resubmitRequested = pyqtSignal(object)
removeRequested = pyqtSignal(object) removeRequested = pyqtSignal(object)
@@ -460,10 +461,14 @@ class PDDTaskPage(QWidget):
self.invertSelectionButton.setToolTip("反选当前已加载的任务") self.invertSelectionButton.setToolTip("反选当前已加载的任务")
self.invertSelectionButton.setEnabled(False) self.invertSelectionButton.setEnabled(False)
self.rerunButton = PushButton(FIF.UPDATE, "重新执行", self) self.rerunButton = PushButton(FIF.UPDATE, "重新采集", self)
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") self.rerunButton.setAccessibleName("重新采集勾选的采集任务")
self.rerunButton.setEnabled(False) 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 = PushButton(FIF.SEND, "重新上报", self)
self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果") self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果")
self.resubmitButton.setEnabled(False) self.resubmitButton.setEnabled(False)
@@ -489,7 +494,7 @@ class PDDTaskPage(QWidget):
if compact == self._compact_commands and self.commandLayout.count(): if compact == self._compact_commands and self.commandLayout.count():
return return
self._compact_commands = compact self._compact_commands = compact
for column in range(16): for column in range(17):
self.commandLayout.setColumnStretch(column, 0) self.commandLayout.setColumnStretch(column, 0)
if compact: if compact:
@@ -506,9 +511,10 @@ class PDDTaskPage(QWidget):
(self.selectAllButton, 2, 5, 1, 1), (self.selectAllButton, 2, 5, 1, 1),
(self.invertSelectionButton, 2, 6, 1, 1), (self.invertSelectionButton, 2, 6, 1, 1),
(self.checkedCountLabel, 2, 7, 1, 1), (self.checkedCountLabel, 2, 7, 1, 1),
(self.rerunButton, 2, 8, 1, 1), (self.resubmitButton, 2, 8, 1, 1),
(self.resubmitButton, 2, 9, 1, 1), (self.rerunButton, 2, 9, 1, 1),
(self.removeButton, 2, 10, 1, 1), (self.purchaseRerunButton, 2, 10, 1, 1),
(self.removeButton, 2, 11, 1, 1),
) )
self.commandLayout.setColumnStretch(4, 1) self.commandLayout.setColumnStretch(4, 1)
else: else:
@@ -525,9 +531,10 @@ class PDDTaskPage(QWidget):
(self.selectAllButton, 0, 10, 1, 1), (self.selectAllButton, 0, 10, 1, 1),
(self.invertSelectionButton, 0, 11, 1, 1), (self.invertSelectionButton, 0, 11, 1, 1),
(self.checkedCountLabel, 0, 12, 1, 1), (self.checkedCountLabel, 0, 12, 1, 1),
(self.rerunButton, 0, 13, 1, 1), (self.resubmitButton, 0, 13, 1, 1),
(self.resubmitButton, 0, 14, 1, 1), (self.rerunButton, 0, 14, 1, 1),
(self.removeButton, 0, 15, 1, 1), (self.purchaseRerunButton, 0, 15, 1, 1),
(self.removeButton, 0, 16, 1, 1),
) )
self.commandLayout.setColumnStretch(6, 4) self.commandLayout.setColumnStretch(6, 4)
self.commandLayout.setColumnStretch(9, 1) self.commandLayout.setColumnStretch(9, 1)
@@ -619,6 +626,7 @@ class PDDTaskPage(QWidget):
self.taskModel.invert_loaded_task_checks self.taskModel.invert_loaded_task_checks
) )
self.rerunButton.clicked.connect(self._request_rerun) self.rerunButton.clicked.connect(self._request_rerun)
self.purchaseRerunButton.clicked.connect(self._request_purchase_rerun)
self.resubmitButton.clicked.connect(self._request_resubmit) self.resubmitButton.clicked.connect(self._request_resubmit)
self.removeButton.clicked.connect(self._request_remove) self.removeButton.clicked.connect(self._request_remove)
self.clearFiltersButton.clicked.connect(self.clear_filters) self.clearFiltersButton.clicked.connect(self.clear_filters)
@@ -636,7 +644,7 @@ class PDDTaskPage(QWidget):
def resizeEvent(self, event) -> None: 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) super().resizeEvent(event)
def set_tasks(self, tasks: Iterable[TaskRow]) -> None: def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
@@ -750,8 +758,15 @@ class PDDTaskPage(QWidget):
if self._rerun_state != "idle": if self._rerun_state != "idle":
return return
task_ids = self.taskModel.checked_task_ids() task_ids = self.taskModel.checked_task_ids()
if len(task_ids) == 1: if task_ids:
self.rerunRequested.emit(task_ids[0]) 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: def _request_resubmit(self) -> None:
if self._resubmit_running or self._remove_running: if self._resubmit_running or self._remove_running:
@@ -773,6 +788,7 @@ class PDDTaskPage(QWidget):
self.rerunButton.setAccessibleName("停止当前重新采集") self.rerunButton.setAccessibleName("停止当前重新采集")
self.rerunButton.setEnabled(True) self.rerunButton.setEnabled(True)
self.resubmitButton.setEnabled(False) self.resubmitButton.setEnabled(False)
self.purchaseRerunButton.setEnabled(False)
self.removeButton.setEnabled(False) self.removeButton.setEnabled(False)
self.selectAllButton.setEnabled(False) self.selectAllButton.setEnabled(False)
self.invertSelectionButton.setEnabled(False) self.invertSelectionButton.setEnabled(False)
@@ -783,21 +799,26 @@ class PDDTaskPage(QWidget):
self.rerunButton.setAccessibleName("正在停止重新采集") self.rerunButton.setAccessibleName("正在停止重新采集")
self.rerunButton.setEnabled(False) self.rerunButton.setEnabled(False)
self.resubmitButton.setEnabled(False) self.resubmitButton.setEnabled(False)
self.purchaseRerunButton.setEnabled(False)
self.removeButton.setEnabled(False) self.removeButton.setEnabled(False)
self.selectAllButton.setEnabled(False) self.selectAllButton.setEnabled(False)
self.invertSelectionButton.setEnabled(False) self.invertSelectionButton.setEnabled(False)
self.autoFetchButton.setEnabled(False) self.autoFetchButton.setEnabled(False)
return return
self.rerunButton.setText("重新执行") self.rerunButton.setText("重新采集")
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") self.rerunButton.setAccessibleName("重新采集勾选的采集任务")
self.purchaseRerunButton.setText("重新采购")
idle = not self._resubmit_running and not self._remove_running idle = not self._resubmit_running and not self._remove_running
has_tasks = self.taskModel.data_row_count() > 0 has_tasks = self.taskModel.data_row_count() > 0
self.autoFetchButton.setEnabled(idle) self.autoFetchButton.setEnabled(idle)
self.selectAllButton.setEnabled(idle and has_tasks) self.selectAllButton.setEnabled(idle and has_tasks)
self.invertSelectionButton.setEnabled(idle and has_tasks) self.invertSelectionButton.setEnabled(idle and has_tasks)
self.rerunButton.setEnabled( 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( self.resubmitButton.setEnabled(
idle and bool(self.taskModel.checked_task_ids()) idle and bool(self.taskModel.checked_task_ids())
+171 -25
View File
@@ -50,21 +50,29 @@ from .pdd_device_service import (
PersistentPddDeviceService, PersistentPddDeviceService,
bind_thread_device_service, bind_thread_device_service,
) )
from .purchase_task_service import PurchaseAdapterFactory from .purchase_task_service import (
from .purchase_task_service import LivePurchaseAdapterFactory LivePurchaseAdapterFactory,
from .purchase_reconcile_service import PurchaseReconcileFactory PurchaseAdapterFactory,
PurchaseTaskService,
)
from .purchase_reconcile_service import (
PurchaseReconcileFactory,
PurchaseReconcileService,
)
from .selected_android_device_service import SelectedAndroidDeviceService from .selected_android_device_service import SelectedAndroidDeviceService
from .settings_repository import SettingsRepository from .settings_repository import SettingsRepository
from .task_models import ( from .task_models import (
OutboxEventType, OutboxEventType,
OutboxStatus, OutboxStatus,
TaskFilters, TaskFilters,
TaskRerunBatch,
TaskStatus, TaskStatus,
TaskSummary, TaskSummary,
TaskType, TaskType,
) )
from .task_repository import ( from .task_repository import (
CollectRerunError, CollectRerunError,
PurchaseRerunError,
TaskRemovalError, TaskRemovalError,
TaskRepository, TaskRepository,
) )
@@ -185,8 +193,8 @@ class ClaimTaskWorker(QObject):
self.run_selected(self._selected_task_id) self.run_selected(self._selected_task_id)
@pyqtSlot(str) @pyqtSlot(object)
def run_selected(self, selected_task_id: str = "") -> None: def run_selected(self, command="") -> None:
"""执行一条命令;该槽始终由固定设备 QThread 串行调用。""" """执行一条命令;该槽始终由固定设备 QThread 串行调用。"""
if not self._accepting: if not self._accepting:
@@ -196,7 +204,7 @@ class ClaimTaskWorker(QObject):
assert self._idle_timer is not None assert self._idle_timer is not None
self._idle_timer.stop() self._idle_timer.stop()
try: try:
if self._cancelled and not selected_task_id: if self._cancelled and not command:
return return
with bind_thread_device_service(self._device_service): with bind_thread_device_service(self._device_service):
client_settings = self._client_service.load() client_settings = self._client_service.load()
@@ -209,6 +217,13 @@ class ClaimTaskWorker(QObject):
client_settings.client_id, client_settings.client_id,
client_settings.client_name, 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: if selected_task_id:
self._device_connection_checker(android_serial or "") self._device_connection_checker(android_serial or "")
self._task_repository.prepare_collect_rerun( self._task_repository.prepare_collect_rerun(
@@ -276,6 +291,90 @@ class ClaimTaskWorker(QObject):
) )
self.completed.emit() 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() @pyqtSlot()
def release_device(self) -> None: def release_device(self) -> None:
"""停止、换设备或配置变化后,在设备线程安全释放缓存。""" """停止、换设备或配置变化后,在设备线程安全释放缓存。"""
@@ -419,7 +518,7 @@ class TaskRemoveWorker(QObject):
class PDDTaskPageEvent(QObject): class PDDTaskPageEvent(QObject):
"""把 PDD 页面只读操作连接到本地任务 Repository。""" """把 PDD 页面只读操作连接到本地任务 Repository。"""
_claimRunRequested = pyqtSignal(str) _claimRunRequested = pyqtSignal(object)
_claimReleaseRequested = pyqtSignal() _claimReleaseRequested = pyqtSignal()
_claimShutdownRequested = pyqtSignal() _claimShutdownRequested = pyqtSignal()
_claimGatewayChanged = pyqtSignal(object) _claimGatewayChanged = pyqtSignal(object)
@@ -452,6 +551,11 @@ class PDDTaskPageEvent(QObject):
self._claim_worker: Optional[ClaimTaskWorker] = None self._claim_worker: Optional[ClaimTaskWorker] = None
self._claim_operation = "" self._claim_operation = ""
self._rerun_cancel_requested = False 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._rerun_feedback: Optional[InfoBar] = None
self._claim_feedback: Optional[InfoBar] = None self._claim_feedback: Optional[InfoBar] = None
self._device_feedback: Optional[InfoBar] = None self._device_feedback: Optional[InfoBar] = None
@@ -517,6 +621,7 @@ class PDDTaskPageEvent(QObject):
page.searchRequested.connect(self.search_tasks) page.searchRequested.connect(self.search_tasks)
page.refreshRequested.connect(self.refresh_tasks) page.refreshRequested.connect(self.refresh_tasks)
page.rerunRequested.connect(self.request_rerun) page.rerunRequested.connect(self.request_rerun)
page.purchaseRerunRequested.connect(self.request_purchase_rerun)
page.rerunCancelRequested.connect(self.request_cancel_rerun) page.rerunCancelRequested.connect(self.request_cancel_rerun)
page.resubmitRequested.connect(self.request_resubmit) page.resubmitRequested.connect(self.request_resubmit)
page.removeRequested.connect(self.request_remove) page.removeRequested.connect(self.request_remove)
@@ -574,11 +679,23 @@ class PDDTaskPageEvent(QObject):
self._reload() self._reload()
@pyqtSlot(str) @pyqtSlot(object)
def request_rerun(self, task_id: str) -> None: 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 return
if ( if (
self._auto_fetch_running self._auto_fetch_running
@@ -616,8 +733,8 @@ class PDDTaskPageEvent(QObject):
) )
return return
try: try:
detail = self._repository.validate_collect_rerun(task_id) plan = self._repository.plan_rerun_batch(stable_ids, target_type)
except (CollectRerunError, ValueError) as exc: except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
self._show_rerun_warning("不能重新执行", str(exc)) self._show_rerun_warning("不能重新执行", str(exc))
return return
except Exception: except Exception:
@@ -626,20 +743,32 @@ class PDDTaskPageEvent(QObject):
) )
return 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( dialog = MessageBox(
"确认重新采集", f"确认重新{action} {len(plan.eligible_task_ids)} 条任务?",
f"任务:{detail.remote_task_id}\n商品:{title}\n\n" f"已选 {plan.selected_count} 条;可执行 {len(plan.eligible_task_ids)} 条;"
"新结果会覆盖 Client 和 Admin 的当前采集数据,旧结果仍保留在执行记录中。", f"过滤其他类型 {len(plan.filtered_task_ids)} 条;安全条件阻止 {len(plan.blocked)} 条。\n\n"
+ ("采购任务会逐条执行;核单不确定时立即停止剩余任务,绝不重新下单。"
if target_type is TaskType.PURCHASE else
"新结果会覆盖 Client 和 Admin 当前采集数据,旧执行记录仍保留。"),
self._page.window(), self._page.window(),
) )
dialog.yesButton.setText("重新采集") dialog.yesButton.setText(f"重新{action}")
dialog.cancelButton.setText("暂不重新采集") dialog.cancelButton.setText("取消")
dialog.cancelButton.setFocus() dialog.cancelButton.setFocus()
if not dialog.exec(): if not dialog.exec():
return return
self._start_rerun_worker(task_id) self._start_rerun_worker(
TaskRerunBatch(target_type, plan.eligible_task_ids)
)
@pyqtSlot(object) @pyqtSlot(object)
def request_resubmit(self, task_ids) -> None: def request_resubmit(self, task_ids) -> None:
@@ -833,7 +962,7 @@ class PDDTaskPageEvent(QObject):
if not self._closing: if not self._closing:
self._page.set_resubmit_running(False) 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 assert self._claim_gateway is not None
@@ -841,17 +970,21 @@ class PDDTaskPageEvent(QObject):
self._close_claim_feedback() self._close_claim_feedback()
self._close_device_feedback() self._close_device_feedback()
self._rerun_cancel_requested = False 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._claim_busy = True
self._page.set_rerun_running(True) self._page.set_rerun_running(True)
self._page.set_engine_status( 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() self._reload()
worker = self._ensure_claim_executor() worker = self._ensure_claim_executor()
self._claim_operation = "rerun" self._claim_operation = "rerun"
worker.prepare_run() worker.prepare_run()
self._claimRunRequested.emit(task_id) self._claimRunRequested.emit(command)
@pyqtSlot() @pyqtSlot()
def request_cancel_rerun(self) -> None: def request_cancel_rerun(self) -> None:
@@ -884,9 +1017,13 @@ class PDDTaskPageEvent(QObject):
"停止请求已处理,请查看任务最新状态" "停止请求已处理,请查看任务最新状态"
) )
return 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) self._page.set_engine_status(message)
if kind != "succeeded":
self._show_rerun_error("重新采集需要处理", message)
@pyqtSlot(str) @pyqtSlot(str)
def _on_rerun_failed(self, message: str) -> None: def _on_rerun_failed(self, message: str) -> None:
@@ -919,6 +1056,15 @@ class PDDTaskPageEvent(QObject):
self._page.set_engine_status( 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: def _show_rerun_warning(self, title: str, content: str) -> None:
bar = InfoBar.warning( bar = InfoBar.warning(
+12
View File
@@ -549,12 +549,24 @@ class PurchaseTaskService:
f"任务 {task_id} 采购演练未完成,失败信息已提交 Admin", f"任务 {task_id} 采购演练未完成,失败信息已提交 Admin",
task_id, 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( return PurchaseTaskOutcome(
"succeeded", "succeeded",
f"任务 {task_id} 演练完成并已提交 Admin;没有提交订单", f"任务 {task_id} 演练完成并已提交 Admin;没有提交订单",
task_id, task_id,
) )
def submit_saved_event(self, event: OutboxEventRecord) -> PurchaseTaskOutcome:
"""提交核单后已经落库的 Outbox,供批量采购闭环复用。"""
return self._submit(event)
def _result_data( def _result_data(
self, target: PurchaseTarget, state: PurchasePageState self, target: PurchaseTarget, state: PurchasePageState
) -> dict[str, object]: ) -> dict[str, object]:
+20 -1
View File
@@ -5,7 +5,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Dict, Mapping, Optional from typing import Any, Dict, Mapping, Optional, Tuple
class TaskType(str, Enum): class TaskType(str, Enum):
@@ -104,6 +104,25 @@ class TaskFilters:
keyword: str = "" 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) @dataclass(frozen=True)
class TaskSummary: class TaskSummary:
"""任务表格使用的轻量数据,不包含完整 JSON。""" """任务表格使用的轻量数据,不包含完整 JSON。"""
+113
View File
@@ -20,6 +20,7 @@ from .task_models import (
StartedTaskRun, StartedTaskRun,
TaskDetail, TaskDetail,
TaskFilters, TaskFilters,
TaskRerunPlan,
TaskStatus, TaskStatus,
TaskSummary, TaskSummary,
TaskType, TaskType,
@@ -39,6 +40,10 @@ class CollectRerunError(ValueError):
"""当前任务不满足重新采集条件。""" """当前任务不满足重新采集条件。"""
class PurchaseRerunError(ValueError):
"""当前任务不满足安全重新采购条件。"""
class TaskRemovalError(ValueError): class TaskRemovalError(ValueError):
"""勾选任务不满足从普通列表移除的安全条件。""" """勾选任务不满足从普通列表移除的安全条件。"""
@@ -472,6 +477,42 @@ class TaskRepository:
connection.close() connection.close()
return self._to_detail(row) 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: def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail:
"""事务内把已结束的采集任务恢复为待执行,保留旧结果。""" """事务内把已结束的采集任务恢复为待执行,保留旧结果。"""
@@ -508,6 +549,7 @@ class TaskRepository:
if row["task_type"] != TaskType.COLLECT.value: if row["task_type"] != TaskType.COLLECT.value:
raise CollectRerunError("采购任务不能重新执行,以免重复下单") raise CollectRerunError("采购任务不能重新执行,以免重复下单")
allowed_statuses = { allowed_statuses = {
TaskStatus.CLAIMED.value,
TaskStatus.RETRY_WAIT.value, TaskStatus.RETRY_WAIT.value,
TaskStatus.SUCCEEDED.value, TaskStatus.SUCCEEDED.value,
TaskStatus.FAILED.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( def start_collect_run(
self, remote_task_id: str, device_address: str self, remote_task_id: str, device_address: str
) -> StartedTaskRun: ) -> StartedTaskRun:
+6 -2
View File
@@ -94,6 +94,9 @@ class TaskTableModelCheckTests(unittest.TestCase):
refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton)) refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton))
rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton)) rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton))
resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton)) resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton))
purchase_position = layout.getItemPosition(
layout.indexOf(page.purchaseRerunButton)
)
remove_position = layout.getItemPosition(layout.indexOf(page.removeButton)) remove_position = layout.getItemPosition(layout.indexOf(page.removeButton))
select_all_position = layout.getItemPosition( select_all_position = layout.getItemPosition(
layout.indexOf(page.selectAllButton) layout.indexOf(page.selectAllButton)
@@ -104,8 +107,9 @@ class TaskTableModelCheckTests(unittest.TestCase):
self.assertEqual(refresh_position[1], search_position[1] + 1) self.assertEqual(refresh_position[1], search_position[1] + 1)
self.assertEqual(search_position[0], 0) self.assertEqual(search_position[0], 0)
self.assertEqual(select_all_position[0], 0) self.assertEqual(select_all_position[0], 0)
self.assertEqual(resubmit_position[1], rerun_position[1] + 1) self.assertEqual(rerun_position[1], resubmit_position[1] + 1)
self.assertEqual(remove_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(invert_position[1], select_all_position[1] + 1)
self.assertEqual(page.checkedCountLabel.text(), "已选 1 条") self.assertEqual(page.checkedCountLabel.text(), "已选 1 条")
self.assertTrue(page.removeButton.isEnabled()) self.assertTrue(page.removeButton.isEnabled())
+7 -4
View File
@@ -377,7 +377,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.assertEqual(row.price_cents, 3990) self.assertEqual(row.price_cents, 3990)
page.deleteLater() 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(1)
self._add_task(2) self._add_task(2)
page = PDDTaskPage() page = PDDTaskPage()
@@ -385,16 +385,19 @@ class PDDTaskPageEventTest(unittest.TestCase):
events.load_initial_tasks() events.load_initial_tasks()
self.assertFalse(page.rerunButton.isEnabled()) self.assertFalse(page.rerunButton.isEnabled())
self.assertFalse(page.purchaseRerunButton.isEnabled())
page.taskModel.setData( page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
) )
self.app.processEvents() self.app.processEvents()
self.assertTrue(page.rerunButton.isEnabled()) self.assertTrue(page.rerunButton.isEnabled())
self.assertTrue(page.purchaseRerunButton.isEnabled())
page.taskModel.setData( page.taskModel.setData(
page.taskModel.index(1, 0), Qt.Checked, Qt.CheckStateRole page.taskModel.index(1, 0), Qt.Checked, Qt.CheckStateRole
) )
self.app.processEvents() self.app.processEvents()
self.assertFalse(page.rerunButton.isEnabled()) self.assertTrue(page.rerunButton.isEnabled())
self.assertTrue(page.purchaseRerunButton.isEnabled())
events.shutdown() events.shutdown()
page.deleteLater() page.deleteLater()
@@ -748,7 +751,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
page.rerunRequested.emit("PDD-001") page.rerunRequested.emit("PDD-001")
message_box.return_value.cancelButton.setText.assert_called_once_with( message_box.return_value.cancelButton.setText.assert_called_once_with(
"暂不重新采集" "取消"
) )
message_box.return_value.cancelButton.setFocus.assert_called_once_with() message_box.return_value.cancelButton.setFocus.assert_called_once_with()
self.assertFalse(events._claim_busy) 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()) self.assertIn("停止请求已处理", page.statusLabel.text())
events.shutdown() events.shutdown()
page.deleteLater() page.deleteLater()
+44 -2
View File
@@ -429,8 +429,10 @@ class TaskRepositoryTests(unittest.TestCase):
self.repository.validate_collect_rerun("PURCHASE-RERUN") self.repository.validate_collect_rerun("PURCHASE-RERUN")
self.repository.add_claimed_task(self._task("ACTIVE-RERUN")) self.repository.add_claimed_task(self._task("ACTIVE-RERUN"))
with self.assertRaisesRegex(CollectRerunError, "待执行"): self.assertEqual(
self.repository.validate_collect_rerun("ACTIVE-RERUN") self.repository.validate_collect_rerun("ACTIVE-RERUN").status,
TaskStatus.CLAIMED,
)
self.repository.add_claimed_task(self._task("UNSENT-RERUN")) self.repository.add_claimed_task(self._task("UNSENT-RERUN"))
started = self.repository.start_collect_run("UNSENT-RERUN", "USB-001") started = self.repository.start_collect_run("UNSENT-RERUN", "USB-001")
@@ -449,6 +451,46 @@ class TaskRepositoryTests(unittest.TestCase):
with self.assertRaisesRegex(CollectRerunError, "未发送"): with self.assertRaisesRegex(CollectRerunError, "未发送"):
self.repository.validate_collect_rerun("UNSENT-RERUN") 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): def test_retry_wait_can_rerun_only_after_failure_outbox_is_sent(self):
self.repository.add_claimed_task(self._task("RETRY-RERUN")) self.repository.add_claimed_task(self._task("RETRY-RERUN"))
first = self.repository.start_collect_run("RETRY-RERUN", "USB-001") first = self.repository.start_collect_run("RETRY-RERUN", "USB-001")
+1 -1
View File
@@ -159,7 +159,7 @@ Qt 主线程
持久设备工作线程(单设备、固定 QThread) 持久设备工作线程(单设备、固定 QThread)
├── uiautomator2 Device 的创建、健康检查、调用和释放 ├── uiautomator2 Device 的创建、健康检查、调用和释放
├── 页面等待与 XML 解析 ├── 页面等待与 XML 解析
└── 采集、采购、订单核对和手动重新执行的串行命令 └── 采集、采购、订单核对和手动批量重新执行的串行命令
结果提交工作线程或同一任务线程的独立队列 结果提交工作线程或同一任务线程的独立队列
└── Outbox 重试,不重复执行 PDD 操作 └── Outbox 重试,不重复执行 PDD 操作
+7 -8
View File
@@ -9,7 +9,7 @@
## 1. 设计目标 ## 1. 设计目标
- 让操作人员在一个页面完成任务监控、搜索和异常定位。 - 让操作人员在一个页面完成任务监控、搜索和异常定位。
- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。 - “获取任务”“重新采集”“重新采购”和“重新上报”会产生外部后果;重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。
- 长任务状态始终可找到,不使用连续模态弹窗打断工作。 - 长任务状态始终可找到,不使用连续模态弹窗打断工作。
- 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。 - 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
- 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。 - 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。
@@ -35,7 +35,7 @@
```text ```text
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ PDD 任务 │ │ PDD 任务 │
│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新执行] [重新上报] [删除] │ │ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新上报] [重新采集] [重新采购] [删除] │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ 选择 │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │ │ 选择 │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │
│ │ │ │
@@ -86,19 +86,18 @@
### 4.3 刷新、全选、反选与批量操作 ### 4.3 刷新、全选、反选与批量操作
- “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。 - “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。
- 页面右侧显示“全选”“反选”和“已选 N 条”,之后依次是“重新执行”“重新上报”“删除”。 - 页面右侧显示“全选”“反选”和“已选 N 条”,之后依次是“重新上报”“重新采集”“重新采购”“删除”。
- “全选”和“反选”只作用于当前筛选条件下已经加载的真实任务行,空白占位行不参与。全选后新领取或继续增量加载的任务默认不勾选,防止批量操作范围在用户不知情时扩大。 - “全选”和“反选”只作用于当前筛选条件下已经加载的真实任务行,空白占位行不参与。全选后新领取或继续增量加载的任务默认不勾选,防止批量操作范围在用户不知情时扩大。
- 没有真实任务,或正在重新执行、重新上报、删除时,禁用“全选”和“反选”。 - 没有真实任务,或正在重新执行、重新上报、删除时,禁用“全选”和“反选”。
- “重新执行”只在恰好勾选一条任务时启用;多选不允许批量操作手机。 - 勾选一条或多条任务后可以点击“重新采集”或“重新采购”。点击后先批量预检,再显示可执行、类型过滤和安全阻止数量;“取消”默认聚焦,点击、按 `Escape` 或关闭弹窗都不得启动任务。
- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。
- 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。 - 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。
- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 - 重新采集只处理采集任务,重新采购只处理采购任务,另一类型计入过滤数量。执行中、结果待提交、仍有未发送 Outbox、已经排队或自动获取忙碌时必须阻止,并用中文说明原因。
- 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。
- 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。 - 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。
- 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。 - 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。
- 重新采集的前置校验警告和后台错误 `InfoBar` 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。 - 重新采集的前置校验警告和后台错误 `InfoBar` 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。
- 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。 - 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。
- 采购任务始终不能通过“重新执行”入口启动;需要处理时由自动获取的安全恢复流程决定。 - 重新采购逐条串行执行。任一任务未形成“执行、核单、上报”成功闭环时立即停止剩余队列。历史上任何一次执行已有 `irreversible_action_at` 的任务永久禁止重新采购,只能只读核单。
- “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示 - “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示
“重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。 “重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。
- “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。 - “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。
@@ -411,7 +410,7 @@ self.show_recoverable_error(
## 10. 键盘与无障碍 ## 10. 键盘与无障碍
- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 全选 → 反选 → 重新执行 → 重新上报 → 删除 → 表格 → 状态区可操作项。 - Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 全选 → 反选 → 重新上报 → 重新采集 → 重新采购 → 删除 → 表格 → 状态区可操作项。
- `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。 - `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。
- 仅图标按钮必须设置准确的无障碍名称和工具提示。 - 仅图标按钮必须设置准确的无障碍名称和工具提示。
- 表单具有可见标签,占位符不能替代标签。 - 表单具有可见标签,占位符不能替代标签。
+1
View File
@@ -77,6 +77,7 @@ Admin 新建采购任务固定为真实下单(不支付),Client 不再提
3. 商品编号、标题、规格、数量、库存和价格保护校验通过。 3. 商品编号、标题、规格、数量、库存和价格保护校验通过。
4. 最终下单前后均有持久化步骤标记。 4. 最终下单前后均有持久化步骤标记。
5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。 5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。
6. 批量重新采购永久拒绝历史任一次执行带不可逆标记的任务;当前任务核单不确定时不启动下一条。
6. 订单匹配能识别唯一候选;多个候选进入人工处理。 6. 订单匹配能识别唯一候选;多个候选进入人工处理。
7. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。 7. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。
8. 真实设备首单按独立验收工单执行,并明确停止在支付前。 8. 真实设备首单按独立验收工单执行,并明确停止在支付前。