feat: 安全继续失败采购核单 (#262)
This commit is contained in:
+119
-11
@@ -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:
|
||||
"""请求当前重新采集在下一个安全点停止。"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user