From b547844dca5f0ccf8d68d18bb7428dbb83318746 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 5 Aug 2026 18:01:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=A8=A1=E5=9E=8B:=20spawn?= =?UTF-8?q?=E6=B4=BE=E5=8F=91=E5=8D=8F=E5=8A=A9=E5=88=86=E6=94=AF=20+=20?= =?UTF-8?q?=E8=BD=AC=E4=BA=A4=E7=AE=80=E5=8C=96=E4=B8=BA=E5=8D=95=E7=BA=BF?= =?UTF-8?q?=20+=20=E5=AD=90=E5=88=86=E6=94=AF=E6=9C=AA=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=8B=A6=E6=88=AA(409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/endpoints/tasks.py | 25 ++++++++ backend/app/services/task_service.py | 62 +++++++++++++++++++ .../pages/scan/components/WorkspaceArea.vue | 3 + track-uniapp/src/pages/scan/detail.vue | 52 +++++++++------- 4 files changed, 121 insertions(+), 21 deletions(-) diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py index 88c3211..a788b4c 100644 --- a/backend/app/api/v1/endpoints/tasks.py +++ b/backend/app/api/v1/endpoints/tasks.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, Query, Body +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -120,6 +121,30 @@ async def end_task_endpoint( return await task_service.end_task(db, uuid.UUID(task_id), operator_id) +# ============================================================ +# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务) +# ============================================================ + +class SpawnRequest(BaseModel): + task_name: str = Field(..., max_length=200, description="工序名称") + assignee_id: str | None = Field(None, max_length=64, description="负责人ID") + remark: str | None = Field(None, max_length=2000, description="派发备注") + + +@router.post("/{task_id}/spawn", response_model=TaskResponse, status_code=201) +async def spawn_subtask_endpoint( + task_id: str, + data: SpawnRequest, + operator_id: str | None = Query(None), + db: AsyncSession = Depends(get_db), +): + """ + **派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。** + 用于 WIP 期间工人需要其他人协助协同的场景。 + """ + return await task_service.spawn_subtask(db, uuid.UUID(task_id), data, operator_id) + + # ============================================================ # 核心业务 1:确认接收 (PENDING → WIP) # ============================================================ diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 32f1eba..2cbb158 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -236,6 +236,9 @@ async def end_task( """ task = await _get_task_or_404(db, task_id) + # 校验:必须等待所有协助分支完成 + await _check_children_done(db, task_id) + if task.status == TASK_STATUS_COMPLETED: raise HTTPException(status_code=409, detail="此分支已经结束") if task.status == TASK_STATUS_PENDING: @@ -253,6 +256,62 @@ async def end_task( return _to_response(task) +# ============================================================ +# 核心业务 0.5:派发协助分支(不改变父任务状态) +# ============================================================ + +async def spawn_subtask( + db: AsyncSession, task_id: uuid.UUID, data, operator_id: str | None = None +) -> TaskResponse: + """在当前任务下创建并行子任务,父任务状态保持不变。""" + task = await _get_task_or_404(db, task_id) + + if task.status == TASK_STATUS_COMPLETED: + raise HTTPException(status_code=409, detail="任务已完成,无法派发协助分支") + if task.status == TASK_STATUS_REJECTED: + raise HTTPException(status_code=409, detail="任务已驳回,无法派发协助分支") + + child = Task( + product_id=task.product_id, + parent_task_id=task.id, + task_name=data.task_name, + assignee_id=data.assignee_id, + status=TASK_STATUS_PENDING, + notify_parent_on_complete=False, + is_rework=False, + remark=data.remark or None, + ) + db.add(child) + await db.flush() + + await _create_task_log(db, child.id, action_type="create", operator_id=operator_id, + remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})") + await db.commit() + await db.refresh(child) + return _to_response(child) + + +async def _check_children_done(db: AsyncSession, task_id: uuid.UUID): + """检查当前任务的所有子任务是否都已完结。未完结则抛出 409。""" + result = await db.execute( + select(Task).where(Task.parent_task_id == task_id) + ) + children = result.scalars().all() + incomplete = [c for c in children if c.status not in ( + TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, ARCHIVED_STATUS + )] + if incomplete: + names = "、".join(c.task_name for c in incomplete) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"当前工序还有未完成的协助分支({names}),必须等待分支结束才能转交或完工!", + ) + return True + + +ARCHIVED_STATUS = "ARCHIVED" + + # ============================================================ # 核心业务 1:确认接收 (PENDING → WIP) # ============================================================ @@ -429,6 +488,9 @@ async def transfer_task( """ task = await _get_task_or_404(db, task_id) + # 校验:必须等待所有协助分支完成 + await _check_children_done(db, task_id) + # 校验:不能重复完成 if task.status == TASK_STATUS_COMPLETED: raise HTTPException( diff --git a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue index 8e5674f..9f58fee 100644 --- a/track-uniapp/src/pages/scan/components/WorkspaceArea.vue +++ b/track-uniapp/src/pages/scan/components/WorkspaceArea.vue @@ -78,6 +78,8 @@ @tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交 + + @@ -141,13 +145,16 @@ export default { currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree", processOptions: [], userOptions: [], actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", - transferForm: { branches: [{ task_name: "", assignee_id: "" }], note: "" }, + transferForm: { next_task_name: "", selectedUserId: "", isWarehouse: false, note: "" }, + spawnForm: { task_name: "", assignee_id: "", remark: "" }, }; }, computed: { userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); }, canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; return this.currentUser.username === this.recordPopup.task.assignee_id; }, - canSubmitTransfer() { return this.transferForm.branches.every(b => { if (!b.task_name) return false; if (b.task_name === "🏭 入库 (virtual_warehouse)") return true; return !!b.assignee_id; }); }, + transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; }, + spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; }, + spawnProcessOptions() { return (this.processOptions || []).filter(o => o !== "🏭 入库 (virtual_warehouse)"); }, hasMyActiveTask() { const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; }; return this.product ? find(this.product.task_tree) : false; @@ -186,8 +193,10 @@ 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 === "transfer") { 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.transferForm = { branches: [{ task_name: "", assignee_id: "" }], note: "" }; + 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.transferForm = { next_task_name: "", selectedUserId: "", isWarehouse: false, note: "" }; + this.spawnForm = { task_name: "", assignee_id: "", remark: "" }; }, 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 {} }, @@ -196,13 +205,14 @@ export default { 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 }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } }, async doReject() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim() }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } }, - addBranch() { if (this.transferForm.branches.length >= 5) { uni.showToast({ title: "最多 5 个分支", icon: "none" }); return; } this.transferForm.branches.push({ task_name: "", assignee_id: "" }); }, - removeBranch(idx) { if (this.transferForm.branches.length > 1) this.transferForm.branches.splice(idx, 1); }, - onBranchProcessChange(idx, e) { this.transferForm.branches[idx].task_name = this.processOptions[e.detail.value]; this.transferForm.branches[idx].assignee_id = ""; }, - onBranchUserChange(idx, e) { const user = this.userOptions[e.detail.value]; if (user) this.transferForm.branches[idx].assignee_id = user.id; }, - isBranchWarehouse(idx) { return this.transferForm.branches[idx].task_name === "🏭 入库 (virtual_warehouse)"; }, - branchUserName(idx) { const u = this.userOptions.find(u => u.id === this.transferForm.branches[idx].assignee_id); return u ? u.name : ""; }, - async doTransfer() { this.actionLoading = true; const branches = this.transferForm.branches.filter(b => b.task_name); try { await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: branches.map(b => ({ task_name: b.task_name, assignees: b.task_name === "🏭 入库 (virtual_warehouse)" ? ["virtual_warehouse"] : [b.assignee_id] })), note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: "派发成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } }, + // 转交(单线) + onTransferProcessChange(e) { this.transferForm.next_task_name = this.processOptions[e.detail.value]; this.transferForm.isWarehouse = this.transferForm.next_task_name === "🏭 入库 (virtual_warehouse)"; this.transferForm.selectedUserId = ""; }, + onTransferUserChange(e) { const user = this.userOptions[e.detail.value]; if (user) this.transferForm.selectedUserId = user.id; }, + async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: this.transferForm.next_task_name, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } }, + // 派发协助分支 + onSpawnProcessChange(e) { this.spawnForm.task_name = this.spawnProcessOptions[e.detail.value]; }, + onSpawnUserChange(e) { const user = this.userOptions[e.detail.value]; if (user) this.spawnForm.assignee_id = user.id; }, + async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: this.spawnForm.task_name, assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } }, }, };