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
|
PURCHASE_RECONCILE_DELAY_MS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_reconcile_only_purchase_adapter(*_args):
|
||||||
|
"""继续核单分支绝不允许创建采购 Adapter。"""
|
||||||
|
|
||||||
|
raise RuntimeError("只读核单禁止创建采购执行器")
|
||||||
|
|
||||||
|
|
||||||
class ClaimTaskWorker(QObject):
|
class ClaimTaskWorker(QObject):
|
||||||
"""在固定后台线程中串行补交、领取或执行至多一条任务。"""
|
"""在固定后台线程中串行补交、领取或执行至多一条任务。"""
|
||||||
|
|
||||||
@@ -319,6 +325,14 @@ class ClaimTaskWorker(QObject):
|
|||||||
collect_service_factory=self._collect_service_factory,
|
collect_service_factory=self._collect_service_factory,
|
||||||
)
|
)
|
||||||
result = service.execute_selected(task_id)
|
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:
|
else:
|
||||||
result = self._execute_purchase_rerun(
|
result = self._execute_purchase_rerun(
|
||||||
task_id,
|
task_id,
|
||||||
@@ -329,6 +343,14 @@ class ClaimTaskWorker(QObject):
|
|||||||
)
|
)
|
||||||
except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
|
except (CollectRerunError, PurchaseRerunError, ValueError) as exc:
|
||||||
self.outcome.emit("skipped", str(exc), task_id)
|
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
|
continue
|
||||||
self.outcome.emit(result.kind, result.message, result.task_id or task_id)
|
self.outcome.emit(result.kind, result.message, result.task_id or task_id)
|
||||||
if command.target_type is TaskType.PURCHASE and result.kind != "succeeded":
|
if command.target_type is TaskType.PURCHASE and result.kind != "succeeded":
|
||||||
@@ -340,6 +362,48 @@ class ClaimTaskWorker(QObject):
|
|||||||
)
|
)
|
||||||
break
|
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(
|
def _execute_purchase_rerun(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@@ -768,30 +832,59 @@ class PDDTaskPageEvent(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
action = "采集" if target_type is TaskType.COLLECT else "采购"
|
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(
|
self._show_rerun_warning(
|
||||||
f"没有可重新{action}的任务",
|
f"没有可重新{action}的任务",
|
||||||
f"已选 {plan.selected_count} 条,过滤 {len(plan.filtered_task_ids)} 条其他类型,"
|
f"已选 {plan.selected_count} 条,过滤 {len(plan.filtered_task_ids)} 条其他类型,"
|
||||||
f"安全条件阻止 {len(plan.blocked)} 条。",
|
f"安全条件阻止 {len(plan.blocked)} 条。",
|
||||||
)
|
)
|
||||||
return
|
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(
|
dialog = MessageBox(
|
||||||
f"确认重新{action} {len(plan.eligible_task_ids)} 条任务?",
|
title,
|
||||||
f"已选 {plan.selected_count} 条;可执行 {len(plan.eligible_task_ids)} 条;"
|
detail,
|
||||||
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(f"重新{action}")
|
dialog.yesButton.setText(confirm_text)
|
||||||
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(
|
self._start_rerun_worker(
|
||||||
TaskRerunBatch(target_type, plan.eligible_task_ids)
|
TaskRerunBatch(
|
||||||
|
target_type,
|
||||||
|
processable_task_ids,
|
||||||
|
plan.reconcile_task_ids,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@pyqtSlot(object)
|
@pyqtSlot(object)
|
||||||
@@ -1000,8 +1093,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
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"已加入队列 {len(command.task_ids)} 条,正在检查 Android 设备,等待开始重新"
|
self._rerun_queue_status(command)
|
||||||
f"{'采集' if command.target_type is TaskType.COLLECT else '采购'}…"
|
|
||||||
)
|
)
|
||||||
self._reload()
|
self._reload()
|
||||||
|
|
||||||
@@ -1010,6 +1102,22 @@ class PDDTaskPageEvent(QObject):
|
|||||||
worker.prepare_run()
|
worker.prepare_run()
|
||||||
self._claimRunRequested.emit(command)
|
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()
|
@pyqtSlot()
|
||||||
def request_cancel_rerun(self) -> None:
|
def request_cancel_rerun(self) -> None:
|
||||||
"""请求当前重新采集在下一个安全点停止。"""
|
"""请求当前重新采集在下一个安全点停止。"""
|
||||||
|
|||||||
@@ -106,11 +106,12 @@ class TaskFilters:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TaskRerunPlan:
|
class TaskRerunPlan:
|
||||||
"""批量重新执行预检结果;界面只展示计数,不自行判断安全条件。"""
|
"""批量重新执行预检结果;不可逆采购单独进入只读核单。"""
|
||||||
|
|
||||||
target_type: TaskType
|
target_type: TaskType
|
||||||
selected_count: int
|
selected_count: int
|
||||||
eligible_task_ids: Tuple[str, ...]
|
eligible_task_ids: Tuple[str, ...]
|
||||||
|
reconcile_task_ids: Tuple[str, ...]
|
||||||
filtered_task_ids: Tuple[str, ...]
|
filtered_task_ids: Tuple[str, ...]
|
||||||
blocked: Tuple[Tuple[str, str], ...]
|
blocked: Tuple[Tuple[str, str], ...]
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ class TaskRerunBatch:
|
|||||||
|
|
||||||
target_type: TaskType
|
target_type: TaskType
|
||||||
task_ids: Tuple[str, ...]
|
task_ids: Tuple[str, ...]
|
||||||
|
reconcile_task_ids: Tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ class TaskRepository:
|
|||||||
|
|
||||||
stable_ids = tuple(dict.fromkeys(str(value) for value in remote_task_ids if value))
|
stable_ids = tuple(dict.fromkeys(str(value) for value in remote_task_ids if value))
|
||||||
eligible: List[str] = []
|
eligible: List[str] = []
|
||||||
|
reconcile: List[str] = []
|
||||||
filtered: List[str] = []
|
filtered: List[str] = []
|
||||||
blocked: List[Tuple[str, str]] = []
|
blocked: List[Tuple[str, str]] = []
|
||||||
connection = open_database(self._db_path)
|
connection = open_database(self._db_path)
|
||||||
@@ -516,13 +517,28 @@ class TaskRepository:
|
|||||||
else:
|
else:
|
||||||
self._check_purchase_rerun(connection, row)
|
self._check_purchase_rerun(connection, row)
|
||||||
except (CollectRerunError, PurchaseRerunError) as exc:
|
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:
|
else:
|
||||||
eligible.append(task_id)
|
eligible.append(task_id)
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
return TaskRerunPlan(
|
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:
|
def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail:
|
||||||
@@ -642,6 +658,53 @@ class TaskRepository:
|
|||||||
assert task is not None
|
assert task is not None
|
||||||
return task
|
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
|
@staticmethod
|
||||||
def _check_purchase_rerun(
|
def _check_purchase_rerun(
|
||||||
connection: sqlite3.Connection, row: sqlite3.Row
|
connection: sqlite3.Connection, row: sqlite3.Row
|
||||||
@@ -672,6 +735,45 @@ class TaskRepository:
|
|||||||
if unsent is not None:
|
if unsent is not None:
|
||||||
raise PurchaseRerunError("任务仍有未发送数据,请先点击“重新上报”")
|
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(
|
def start_collect_run(
|
||||||
self, remote_task_id: str, device_address: str
|
self, remote_task_id: str, device_address: str
|
||||||
) -> StartedTaskRun:
|
) -> StartedTaskRun:
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
number: int,
|
number: int,
|
||||||
task_type: TaskType = TaskType.COLLECT,
|
task_type: TaskType = TaskType.COLLECT,
|
||||||
title: str = "测试商品",
|
title: str = "测试商品",
|
||||||
|
execution_mode: str = "dry_run",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.repository.add_claimed_task(
|
self.repository.add_claimed_task(
|
||||||
NewClaimedTask(
|
NewClaimedTask(
|
||||||
@@ -325,6 +326,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
target_size="M",
|
target_size="M",
|
||||||
price_cent=3990,
|
price_cent=3990,
|
||||||
quantity=1 if task_type is TaskType.PURCHASE else None,
|
quantity=1 if task_type is TaskType.PURCHASE else None,
|
||||||
|
execution_mode=execution_mode,
|
||||||
),
|
),
|
||||||
received_at="2026-08-06T08:00:00Z",
|
received_at="2026-08-06T08:00:00Z",
|
||||||
)
|
)
|
||||||
@@ -762,6 +764,73 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
events.shutdown()
|
events.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_purchase_rerun_confirmation_routes_reconcile_before_purchase(self):
|
||||||
|
self._add_task(
|
||||||
|
1,
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
execution_mode="live",
|
||||||
|
)
|
||||||
|
self._add_task(
|
||||||
|
2,
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
execution_mode="live",
|
||||||
|
)
|
||||||
|
started = self.repository.start_purchase_run("PDD-001", "USB-001")
|
||||||
|
self.repository.mark_purchase_irreversible(
|
||||||
|
"PDD-001",
|
||||||
|
started.attempt_id,
|
||||||
|
{
|
||||||
|
"options": {"color": "黑色", "size": "M"},
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price_cent": 3990,
|
||||||
|
"total_price_cent": 3990,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.repository.move_purchase_to_reconcile(
|
||||||
|
"PDD-001", started.attempt_id
|
||||||
|
)
|
||||||
|
self.repository.save_purchase_reconciliation(
|
||||||
|
"PDD-001",
|
||||||
|
started.attempt_id,
|
||||||
|
"unknown",
|
||||||
|
{"error_code": "RECONCILE_READ_FAILED"},
|
||||||
|
)
|
||||||
|
connection = open_database(self.db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pdd_tasks SET status = 'failed'"
|
||||||
|
" WHERE remote_task_id = 'PDD-002'"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
page = PDDTaskPage()
|
||||||
|
events = PDDTaskPageEvent(
|
||||||
|
page,
|
||||||
|
self.repository,
|
||||||
|
claim_gateway=RecordingClaimGateway(),
|
||||||
|
settings_repository=self._saved_settings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("src.pdd_ui_event.MessageBox") as message_box,
|
||||||
|
patch.object(events, "_start_rerun_worker") as start_worker,
|
||||||
|
):
|
||||||
|
message_box.return_value.exec.return_value = True
|
||||||
|
page.purchaseRerunRequested.emit(("PDD-002", "PDD-001"))
|
||||||
|
|
||||||
|
detail = message_box.call_args.args[1]
|
||||||
|
self.assertIn("可重新采购 1 条", detail)
|
||||||
|
self.assertIn("只继续核单 1 条", detail)
|
||||||
|
message_box.return_value.yesButton.setText.assert_called_once_with(
|
||||||
|
"继续处理"
|
||||||
|
)
|
||||||
|
command = start_worker.call_args.args[0]
|
||||||
|
self.assertEqual(command.task_ids, ("PDD-001", "PDD-002"))
|
||||||
|
self.assertEqual(command.reconcile_task_ids, ("PDD-001",))
|
||||||
|
events.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
def test_running_rerun_can_request_cooperative_cancel_once(self):
|
def test_running_rerun_can_request_cooperative_cancel_once(self):
|
||||||
self._add_task(1)
|
self._add_task(1)
|
||||||
first = self.repository.start_collect_run("PDD-001", "USB-001")
|
first = self.repository.start_collect_run("PDD-001", "USB-001")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
@@ -19,8 +20,9 @@ from src.pdd_purchase_reconcile_adapter import (
|
|||||||
PurchaseReconcileScan,
|
PurchaseReconcileScan,
|
||||||
)
|
)
|
||||||
from src.purchase_task_service import PurchaseTaskService
|
from src.purchase_task_service import PurchaseTaskService
|
||||||
|
from src.pdd_ui_event import ClaimTaskWorker
|
||||||
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
||||||
from src.task_models import RunStatus, TaskStatus, TaskType
|
from src.task_models import RunStatus, TaskRerunBatch, TaskStatus, TaskType
|
||||||
from src.task_repository import TaskRepository
|
from src.task_repository import TaskRepository
|
||||||
|
|
||||||
|
|
||||||
@@ -348,6 +350,115 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
run.diagnostics_json["reconciliation"]["error_message"],
|
run.diagnostics_json["reconciliation"]["error_message"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_manual_reconcile_retry_reuses_run_and_never_purchases(self):
|
||||||
|
task_id = "PUR-RECONCILE-RETRY"
|
||||||
|
self._interrupt_after_irreversible(task_id)
|
||||||
|
first = TaskDispatcher(
|
||||||
|
self.gateway,
|
||||||
|
self.repository,
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
purchase_reconcile_factory=(
|
||||||
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
|
||||||
|
[], error=ConnectionError("首次核单失败")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
|
).execute_one()
|
||||||
|
self.assertEqual(first.kind, "manual_review")
|
||||||
|
before = self.repository.latest_task_run(task_id)
|
||||||
|
assert before is not None
|
||||||
|
purchase_calls = []
|
||||||
|
reconcile_calls = []
|
||||||
|
worker = ClaimTaskWorker(
|
||||||
|
self.gateway,
|
||||||
|
self.repository,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
live_purchase_adapter_factory=(
|
||||||
|
lambda *_args: purchase_calls.append("purchase")
|
||||||
|
),
|
||||||
|
purchase_reconcile_factory=(
|
||||||
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
|
||||||
|
reconcile_calls
|
||||||
|
)
|
||||||
|
),
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
|
)
|
||||||
|
outcomes = []
|
||||||
|
worker.outcome.connect(
|
||||||
|
lambda kind, message, result_task_id: outcomes.append(
|
||||||
|
(kind, message, result_task_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
worker._execute_rerun_batch(
|
||||||
|
TaskRerunBatch(
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
(task_id,),
|
||||||
|
(task_id,),
|
||||||
|
),
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
after = self.repository.latest_task_run(task_id)
|
||||||
|
assert after is not None
|
||||||
|
self.assertEqual(outcomes[0][0], "succeeded")
|
||||||
|
self.assertEqual(purchase_calls, [])
|
||||||
|
self.assertEqual(
|
||||||
|
reconcile_calls.count(("reconcile", "737116531267")), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(after.attempt_id, before.attempt_id)
|
||||||
|
self.assertEqual(after.attempt_no, before.attempt_no)
|
||||||
|
self.assertEqual(
|
||||||
|
after.irreversible_action_at, before.irreversible_action_at
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get_task(task_id).status,
|
||||||
|
TaskStatus.SUCCEEDED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconcile_retry_failure_stops_following_purchase(self):
|
||||||
|
worker = ClaimTaskWorker(
|
||||||
|
self.gateway,
|
||||||
|
self.repository,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
|
)
|
||||||
|
outcomes = []
|
||||||
|
worker.outcome.connect(
|
||||||
|
lambda kind, message, task_id: outcomes.append(
|
||||||
|
(kind, message, task_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
worker,
|
||||||
|
"_execute_purchase_reconcile_retry",
|
||||||
|
side_effect=ValueError("核单状态已变化"),
|
||||||
|
) as reconcile,
|
||||||
|
patch.object(worker, "_execute_purchase_rerun") as purchase,
|
||||||
|
):
|
||||||
|
worker._execute_rerun_batch(
|
||||||
|
TaskRerunBatch(
|
||||||
|
TaskType.PURCHASE,
|
||||||
|
("PUR-RECONCILE", "PUR-NEW"),
|
||||||
|
("PUR-RECONCILE",),
|
||||||
|
),
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
reconcile.assert_called_once()
|
||||||
|
purchase.assert_not_called()
|
||||||
|
self.assertEqual(
|
||||||
|
[item[0] for item in outcomes], ["skipped", "skipped"]
|
||||||
|
)
|
||||||
|
self.assertIn("未开始", outcomes[1][1])
|
||||||
|
|
||||||
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
|
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
|
||||||
cases = {
|
cases = {
|
||||||
"EMPTY": ((), "ORDER_NOT_FOUND", False),
|
"EMPTY": ((), "ORDER_NOT_FOUND", False),
|
||||||
|
|||||||
@@ -608,8 +608,76 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(plan.eligible_task_ids, ())
|
self.assertEqual(plan.eligible_task_ids, ())
|
||||||
|
self.assertEqual(plan.reconcile_task_ids, ())
|
||||||
self.assertIn("只准核对订单", plan.blocked[0][1])
|
self.assertIn("只准核对订单", plan.blocked[0][1])
|
||||||
|
|
||||||
|
def test_purchase_rerun_plan_separates_safe_reconcile_retry(self):
|
||||||
|
self.repository.add_claimed_task(
|
||||||
|
self._task("PURCHASE-RECONCILE", TaskType.PURCHASE)
|
||||||
|
)
|
||||||
|
started = self.repository.start_purchase_run(
|
||||||
|
"PURCHASE-RECONCILE", "USB-001"
|
||||||
|
)
|
||||||
|
irreversible_at = "2026-08-11T00:00:00Z"
|
||||||
|
connection = open_database(self.db_path)
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE task_runs SET run_status = 'manual_review',"
|
||||||
|
" current_step = 'reconcile_manual_review',"
|
||||||
|
" irreversible_action_at = ?, error_code = 'ORDER_MATCH_UNCERTAIN'"
|
||||||
|
" WHERE attempt_id = ?",
|
||||||
|
(irreversible_at, started.attempt_id),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pdd_tasks SET status = 'manual_review',"
|
||||||
|
" current_step = 'reconcile_manual_review',"
|
||||||
|
" last_error_code = 'ORDER_MATCH_UNCERTAIN'"
|
||||||
|
" WHERE remote_task_id = ?",
|
||||||
|
("PURCHASE-RECONCILE",),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
plan = self.repository.plan_rerun_batch(
|
||||||
|
("PURCHASE-RECONCILE",), TaskType.PURCHASE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(plan.eligible_task_ids, ())
|
||||||
|
self.assertEqual(
|
||||||
|
plan.reconcile_task_ids, ("PURCHASE-RECONCILE",)
|
||||||
|
)
|
||||||
|
self.assertEqual(plan.blocked, ())
|
||||||
|
|
||||||
|
before = self.repository.latest_task_run("PURCHASE-RECONCILE")
|
||||||
|
prepared = self.repository.prepare_purchase_reconcile_retry(
|
||||||
|
"PURCHASE-RECONCILE"
|
||||||
|
)
|
||||||
|
after = self.repository.latest_task_run("PURCHASE-RECONCILE")
|
||||||
|
|
||||||
|
assert before is not None and after is not None
|
||||||
|
self.assertEqual(prepared.current_step, "reconcile_purchase")
|
||||||
|
self.assertEqual(after.current_step, "reconcile_purchase")
|
||||||
|
self.assertEqual(after.attempt_id, before.attempt_id)
|
||||||
|
self.assertEqual(after.attempt_no, before.attempt_no)
|
||||||
|
self.assertEqual(after.irreversible_action_at, irreversible_at)
|
||||||
|
self.assertEqual(after.diagnostics_json, before.diagnostics_json)
|
||||||
|
|
||||||
|
def test_purchase_before_irreversible_remains_eligible_for_rerun(self):
|
||||||
|
self.repository.add_claimed_task(
|
||||||
|
self._task("PURCHASE-BEFORE-SUBMIT", TaskType.PURCHASE)
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = self.repository.plan_rerun_batch(
|
||||||
|
("PURCHASE-BEFORE-SUBMIT",), TaskType.PURCHASE
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
plan.eligible_task_ids, ("PURCHASE-BEFORE-SUBMIT",)
|
||||||
|
)
|
||||||
|
self.assertEqual(plan.reconcile_task_ids, ())
|
||||||
|
self.assertEqual(plan.blocked, ())
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Client 首次显示主窗口时使用标准 Windows 最大化状态,不使用
|
|||||||
- 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。
|
- 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。
|
||||||
- 重新采集的前置校验警告和后台错误 `InfoBar` 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。
|
- 重新采集的前置校验警告和后台错误 `InfoBar` 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。
|
||||||
- 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。
|
- 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。
|
||||||
- 重新采购逐条串行执行。任一任务未形成“执行、核单、上报”成功闭环时立即停止剩余队列。历史上任何一次执行已有 `irreversible_action_at` 的任务永久禁止重新采购,只能只读核单。
|
- 重新采购逐条串行执行。任一任务未形成“执行、核单、上报”成功闭环时立即停止剩余队列。历史上任何一次执行已有 `irreversible_action_at` 的任务永久禁止重新下单;处于 `reconcile_manual_review` 的最新不可逆运行可以从同一入口继续一次只读核单。确认框必须分别显示“可重新采购”和“只继续核单”数量,只读核单排在真正重新采购之前,核单不确定时停止剩余队列。
|
||||||
- 用户确认批量操作后立即刷新列表,并在状态区显示已加入队列的数量。排队中的任务保持原状态;
|
- 用户确认批量操作后立即刷新列表,并在状态区显示已加入队列的数量。排队中的任务保持原状态;
|
||||||
只有工作线程已经把当前任务写成 `running` 后,才通知主线程刷新该行并显示“采集中”或
|
只有工作线程已经把当前任务写成 `running` 后,才通知主线程刷新该行并显示“采集中”或
|
||||||
“采购中”。每条完成、失败、跳过或取消后再次刷新,再开始下一条。
|
“采购中”。每条完成、失败、跳过或取消后再次刷新,再开始下一条。
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ Admin 新建采购任务固定为真实下单(不支付),Client 不再提
|
|||||||
3. 商品编号、标题、规格、数量、库存和价格保护校验通过。
|
3. 商品编号、标题、规格、数量、库存和价格保护校验通过。
|
||||||
4. 最终下单前后均有持久化步骤标记。
|
4. 最终下单前后均有持久化步骤标记。
|
||||||
5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。
|
5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。
|
||||||
6. 批量重新采购永久拒绝历史任一次执行带不可逆标记的任务;当前任务核单不确定时不启动下一条。
|
6. 批量重新采购永久拒绝历史任一次执行带不可逆标记的任务再次下单;`reconcile_manual_review` 只允许恢复原运行继续只读核单,不创建新运行、不调用采购 Adapter,核单不确定时不启动下一条。
|
||||||
6. 订单匹配能识别唯一候选;多个候选进入人工处理。
|
7. 订单匹配能识别唯一候选;多个候选进入人工处理。
|
||||||
7. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。
|
8. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。
|
||||||
8. 真实设备首单按独立验收工单执行,并明确停止在支付前。
|
9. 真实设备首单按独立验收工单执行,并明确停止在支付前。
|
||||||
|
|
||||||
Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` 只表达本次注册或领取时的运行就绪能力:身份、设备或真实采购 Adapter 任一缺失都声明 `dry_run`,全部就绪才自动声明 `live`。演练 Adapter 不提供提交方法,live Adapter 只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试通过当成真机验收完成。
|
Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` 只表达本次注册或领取时的运行就绪能力:身份、设备或真实采购 Adapter 任一缺失都声明 `dry_run`,全部就绪才自动声明 `live`。演练 Adapter 不提供提交方法,live Adapter 只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试通过当成真机验收完成。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user