撤回转交: recall接口删除子任务恢复父WIP + 前端canRecall判断 + 红色撤回按钮

This commit is contained in:
2026-08-06 11:39:22 +08:00
parent 8be06bcd43
commit 57884d7baa
4 changed files with 68 additions and 0 deletions

View File

@ -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 → 不改变状态,创建子任务)
# ============================================================

View File

@ -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:派发协助分支(不改变父任务状态)
# ============================================================

View File

@ -90,6 +90,10 @@
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
@tap="$emit('action', { task: lockedTask, type: 'reject' })"><text class="btn-icon"></text><text class="btn-txt">驳回任务</text></button>
</view>
<view v-else-if="lockedTask.status === 'PENDING' && canRecall" class="footer-actions">
<button class="footer-btn footer-recall"
@tap="$emit('action', { task: lockedTask, type: 'recall' })"><text class="btn-icon">🔄</text><text class="btn-txt">撤回转交</text></button>
</view>
<view v-else class="footer-actions footer-readonly">
<text class="readonly-hint">🔒 非当前任务指派人仅可查看</text>
</view>
@ -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; }
</style>

View File

@ -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; } },