diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py
index e5065fc..8394f35 100644
--- a/backend/app/api/v1/endpoints/tasks.py
+++ b/backend/app/api/v1/endpoints/tasks.py
@@ -121,6 +121,23 @@ async def end_task_endpoint(
return await task_service.end_task(db, uuid.UUID(task_id), operator_id)
+# ============================================================
+# 核心业务 0.3:撤回转交 (PENDING → 删除 + 恢复父任务)
+# ============================================================
+
+@router.post("/{task_id}/recall", response_model=TaskResponse)
+async def recall_task_endpoint(
+ task_id: str,
+ operator_id: str | None = Query(None),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ **撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
+ 适用场景:转交后发现选错人,在对方接收前撤回。
+ """
+ return await task_service.recall_task(db, uuid.UUID(task_id), operator_id)
+
+
# ============================================================
# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务)
# ============================================================
diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py
index 8c1d857..5e29e39 100644
--- a/backend/app/services/task_service.py
+++ b/backend/app/services/task_service.py
@@ -306,6 +306,48 @@ async def end_task(
return _to_response(task)
+# ============================================================
+# 核心业务 0.3:撤回转交
+# ============================================================
+
+async def recall_task(
+ db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
+) -> TaskResponse:
+ """撤回 PENDING 转交任务,恢复父任务为 WIP。"""
+ task = await _get_task_or_404(db, task_id)
+
+ if task.status != TASK_STATUS_PENDING:
+ raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回")
+
+ if not task.parent_task_id:
+ raise HTTPException(status_code=400, detail="顶层任务无法撤回")
+
+ parent = await _get_task_or_404(db, task.parent_task_id)
+ if parent.status != TASK_STATUS_COMPLETED:
+ raise HTTPException(status_code=409, detail="父任务状态异常,无法撤回")
+
+ # 恢复父任务
+ parent.status = TASK_STATUS_WIP
+ parent.completed_at = None
+
+ # 更新产品位置到父任务负责人
+ product_result = await db.execute(select(Product).where(Product.id == parent.product_id))
+ product = product_result.scalar_one_or_none()
+ if product and parent.assignee_id:
+ product.current_location_id = parent.assignee_id
+ product.overall_status = parent.task_name
+
+ # 删除 PENDING 子任务
+ await db.delete(task)
+
+ await _create_task_log(db, parent.id, action_type="recall", operator_id=operator_id,
+ remark=f"撤回转交「{task.task_name}」→ {task.assignee_id},恢复父任务为 WIP")
+
+ await db.commit()
+ await db.refresh(parent)
+ return _to_response(parent)
+
+
# ============================================================
# 核心业务 0.5:派发协助分支(不改变父任务状态)
# ============================================================
diff --git a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
index a7faf91..53fabc4 100644
--- a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
+++ b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
@@ -90,6 +90,10 @@
+
@@ -132,6 +136,7 @@ export default {
},
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
isAssignee() { if (!this.lockedTask) return false; return this.lockedTask.assignee_id == this.currentUserId || this.lockedTask.assignee_id == this.currentUsername; },
+ canRecall() { if (!this.lockedTask || !this.lockedTask.parent_task_id) return false; const parent = this.taskMap[this.lockedTask.parent_task_id]; return parent && (parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername); },
branchLabelsMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
@@ -223,4 +228,5 @@ export default {
.footer-reject { background: #fce4ec; color: #dc2626; }
.footer-end { background: #fef3c7; color: #b45309; }
.footer-spawn { background: #ede9fe; color: #7c3aed; }
+.footer-recall { background: #fef2f2; color: #dc2626; }
diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue
index 78a96c6..1ac5199 100644
--- a/track-uniapp/src/pages/scan/detail.vue
+++ b/track-uniapp/src/pages/scan/detail.vue
@@ -223,6 +223,7 @@ export default {
if (type === "record") { this.openRecordPopup(task); return; }
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
if (type === "end") { this.confirmEndBranch(task); return; }
+ if (type === "recall") { this.confirmRecall(task); return; }
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); if (!this.processOptions.length) this.processOptions = ["🏭 入库 (virtual_warehouse)", ...TASK_NAME_OPTIONS]; } finally { uni.hideLoading(); } }
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
@@ -231,6 +232,8 @@ export default {
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
confirmEndBranch(task) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); },
+ confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(task); } }); },
+ async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },