feat: 安全继续失败采购核单 (#262)

This commit is contained in:
chengma
2026-08-18 09:55:32 +08:00
parent a92b8746b2
commit 5ea8aa3d68
8 changed files with 480 additions and 20 deletions
+119 -11
View File
@@ -123,6 +123,12 @@ ERROR_FEEDBACK_BUTTON_MIN_HEIGHT = 40
PURCHASE_RECONCILE_DELAY_MS = 5_000
def _reject_reconcile_only_purchase_adapter(*_args):
"""继续核单分支绝不允许创建采购 Adapter。"""
raise RuntimeError("只读核单禁止创建采购执行器")
class ClaimTaskWorker(QObject):
"""在固定后台线程中串行补交、领取或执行至多一条任务。"""
@@ -319,6 +325,14 @@ class ClaimTaskWorker(QObject):
collect_service_factory=self._collect_service_factory,
)
result = service.execute_selected(task_id)
elif task_id in command.reconcile_task_ids:
result = self._execute_purchase_reconcile_retry(
task_id,
client,
android_serial,
current=index + 1,
total=len(command.task_ids),
)
else:
result = self._execute_purchase_rerun(
task_id,
@@ -329,6 +343,14 @@ class ClaimTaskWorker(QObject):
)
except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
self.outcome.emit("skipped", str(exc), task_id)
if command.target_type is TaskType.PURCHASE:
for remaining_id in command.task_ids[index + 1 :]:
self.outcome.emit(
"skipped",
f"前一采购任务未形成成功闭环,任务 {remaining_id} 未开始",
remaining_id,
)
break
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":
@@ -340,6 +362,48 @@ class ClaimTaskWorker(QObject):
)
break
def _execute_purchase_reconcile_retry(
self,
task_id: str,
client: ClientInfo,
android_serial: str,
current: int = 1,
total: int = 1,
):
"""恢复原不可逆运行并只读核单,绝不创建采购运行。"""
if self._purchase_reconcile_factory is None:
raise ValueError("采购核单执行器未就绪;绝不重新下单")
self._task_repository.prepare_purchase_reconcile_retry(task_id)
self.taskStarted.emit(
task_id,
TaskType.PURCHASE.value,
current,
total,
)
result = PurchaseReconcileService(
self._task_repository,
android_serial,
self._purchase_reconcile_factory,
cancelled=lambda: False,
).execute_selected(task_id)
if result.kind != "result_pending":
return result
event = self._task_repository.outbox_for_resubmit(task_id)
if event is None:
return type(result)(
"manual_review", "核单结果未写入待上报队列", task_id
)
submitter = PurchaseTaskService(
self._gateway,
self._task_repository,
client,
android_serial,
_reject_reconcile_only_purchase_adapter,
cancelled=lambda: False,
)
return submitter.submit_saved_event(event)
def _execute_purchase_rerun(
self,
task_id: str,
@@ -768,30 +832,59 @@ class PDDTaskPageEvent(QObject):
return
action = "采集" if target_type is TaskType.COLLECT else "采购"
if not plan.eligible_task_ids:
processable_task_ids = (
plan.reconcile_task_ids + plan.eligible_task_ids
)
if not processable_task_ids:
self._show_rerun_warning(
f"没有可重新{action}的任务",
f"已选 {plan.selected_count} 条,过滤 {len(plan.filtered_task_ids)} 条其他类型,"
f"安全条件阻止 {len(plan.blocked)} 条。",
)
return
reconcile_count = len(plan.reconcile_task_ids)
purchase_count = len(plan.eligible_task_ids)
if target_type is TaskType.PURCHASE:
title = f"确认处理 {len(processable_task_ids)} 条采购任务?"
detail = (
f"已选 {plan.selected_count} 条;可重新采购 {purchase_count} 条;"
f"只继续核单 {reconcile_count} 条;"
f"过滤其他类型 {len(plan.filtered_task_ids)} 条;"
f"安全条件阻止 {len(plan.blocked)} 条。\n\n"
"已提交订单的任务只读取待付款订单,绝不重新下单;"
"只读核单优先执行,结果不确定时立即停止剩余任务。"
)
confirm_text = (
"继续核单"
if reconcile_count and not purchase_count
else "继续处理"
)
else:
title = f"确认重新{action} {len(plan.eligible_task_ids)} 条任务?"
detail = (
f"已选 {plan.selected_count} 条;可执行 {len(plan.eligible_task_ids)} 条;"
f"过滤其他类型 {len(plan.filtered_task_ids)} 条;"
f"安全条件阻止 {len(plan.blocked)} 条。\n\n"
"新结果会覆盖 Client 和 Admin 当前采集数据,旧执行记录仍保留。"
)
confirm_text = f"重新{action}"
dialog = MessageBox(
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 当前采集数据,旧执行记录仍保留。"),
title,
detail,
self._page.window(),
)
dialog.yesButton.setText(f"重新{action}")
dialog.yesButton.setText(confirm_text)
dialog.cancelButton.setText("取消")
dialog.cancelButton.setFocus()
if not dialog.exec():
return
self._start_rerun_worker(
TaskRerunBatch(target_type, plan.eligible_task_ids)
TaskRerunBatch(
target_type,
processable_task_ids,
plan.reconcile_task_ids,
)
)
@pyqtSlot(object)
@@ -1000,8 +1093,7 @@ class PDDTaskPageEvent(QObject):
self._claim_busy = True
self._page.set_rerun_running(True)
self._page.set_engine_status(
f"已加入队列 {len(command.task_ids)} 条,正在检查 Android 设备,等待开始重新"
f"{'采集' if command.target_type is TaskType.COLLECT else '采购'}…"
self._rerun_queue_status(command)
)
self._reload()
@@ -1010,6 +1102,22 @@ class PDDTaskPageEvent(QObject):
worker.prepare_run()
self._claimRunRequested.emit(command)
@staticmethod
def _rerun_queue_status(command: TaskRerunBatch) -> str:
total = len(command.task_ids)
if command.target_type is TaskType.COLLECT:
action = "重新采集"
elif len(command.reconcile_task_ids) == total:
action = "继续核单"
elif command.reconcile_task_ids:
action = "处理采购任务"
else:
action = "重新采购"
return (
f"已加入队列 {total} 条,正在检查 Android 设备,"
f"等待开始{action}…"
)
@pyqtSlot()
def request_cancel_rerun(self) -> None:
"""请求当前重新采集在下一个安全点停止。"""
+3 -1
View File
@@ -106,11 +106,12 @@ class TaskFilters:
@dataclass(frozen=True)
class TaskRerunPlan:
"""批量重新执行预检结果;界面只展示计数,不自行判断安全条件。"""
"""批量重新执行预检结果;不可逆采购单独进入只读核单。"""
target_type: TaskType
selected_count: int
eligible_task_ids: Tuple[str, ...]
reconcile_task_ids: Tuple[str, ...]
filtered_task_ids: Tuple[str, ...]
blocked: Tuple[Tuple[str, str], ...]
@@ -121,6 +122,7 @@ class TaskRerunBatch:
target_type: TaskType
task_ids: Tuple[str, ...]
reconcile_task_ids: Tuple[str, ...] = ()
@dataclass(frozen=True)
+104 -2
View File
@@ -496,6 +496,7 @@ class TaskRepository:
stable_ids = tuple(dict.fromkeys(str(value) for value in remote_task_ids if value))
eligible: List[str] = []
reconcile: List[str] = []
filtered: List[str] = []
blocked: List[Tuple[str, str]] = []
connection = open_database(self._db_path)
@@ -516,13 +517,28 @@ class TaskRepository:
else:
self._check_purchase_rerun(connection, row)
except (CollectRerunError, PurchaseRerunError) as exc:
blocked.append((task_id, str(exc)))
if target_type is TaskType.PURCHASE:
try:
self._check_purchase_reconcile_retry(
connection, row
)
except PurchaseRerunError:
blocked.append((task_id, str(exc)))
else:
reconcile.append(task_id)
else:
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)
target_type,
len(stable_ids),
tuple(eligible),
tuple(reconcile),
tuple(filtered),
tuple(blocked),
)
def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail:
@@ -642,6 +658,53 @@ class TaskRepository:
assert task is not None
return task
def prepare_purchase_reconcile_retry(
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} 不存在"
)
run = self._check_purchase_reconcile_retry(connection, row)
run_cursor = connection.execute(
"UPDATE task_runs SET current_step = 'reconcile_purchase',"
" error_code = NULL, error_message = NULL, updated_at = ?"
" WHERE id = ? AND run_status = 'manual_review'"
" AND current_step = 'reconcile_manual_review'"
" AND irreversible_action_at IS NOT NULL",
(now, run["id"]),
)
if run_cursor.rowcount != 1:
raise PurchaseRerunError(
"采购执行记录状态已变化,不能继续核单"
)
task_cursor = connection.execute(
"UPDATE pdd_tasks SET current_step = 'reconcile_purchase',"
" last_error_code = NULL, last_error_message = NULL,"
" updated_at = ? WHERE id = ? AND status = 'manual_review'"
" AND current_step = 'reconcile_manual_review'",
(now, row["id"]),
)
if task_cursor.rowcount != 1:
raise PurchaseRerunError(
"采购任务状态已变化,不能继续核单"
)
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
@@ -672,6 +735,45 @@ class TaskRepository:
if unsent is not None:
raise PurchaseRerunError("任务仍有未发送数据,请先点击“重新上报”")
@staticmethod
def _check_purchase_reconcile_retry(
connection: sqlite3.Connection, row: sqlite3.Row
) -> sqlite3.Row:
"""只允许失败的最新不可逆运行重新进入只读核单。"""
if row["task_type"] != TaskType.PURCHASE.value:
raise PurchaseRerunError("采集任务不能继续采购核单")
if (
row["status"] != TaskStatus.MANUAL_REVIEW.value
or row["current_step"] != "reconcile_manual_review"
):
raise PurchaseRerunError("采购任务当前不在可继续核单状态")
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(
"任务仍有未发送数据,请先点击“重新上报”"
)
run = connection.execute(
"SELECT id, attempt_id, run_status, current_step,"
" irreversible_action_at FROM task_runs WHERE task_id = ?"
" ORDER BY attempt_no DESC LIMIT 1",
(row["id"],),
).fetchone()
if (
run is None
or run["irreversible_action_at"] is None
or run["run_status"] != RunStatus.MANUAL_REVIEW.value
or run["current_step"] != "reconcile_manual_review"
):
raise PurchaseRerunError(
"采购任务没有可安全继续的只读核单记录"
)
return run
def start_collect_run(
self, remote_task_id: str, device_address: str
) -> StartedTaskRun: