并发模型: spawn派发协助分支 + 转交简化为单线 + 子分支未完成拦截(409)

This commit is contained in:
2026-08-05 18:01:14 +08:00
parent b79d521bad
commit b547844dca
4 changed files with 121 additions and 21 deletions

View File

@ -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)
# ============================================================

View File

@ -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(

View File

@ -78,6 +78,8 @@
@tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
@tap="$emit('action', { task: lockedTask, type: 'record' })">📝 记录/拍照</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-spawn"
@tap="$emit('action', { task: lockedTask, type: 'spawn' })"> 派发协助分支</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🏁 结束分支</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
@ -189,4 +191,5 @@ export default {
.footer-receive { background: #dbeafe; color: #1d4ed8; }
.footer-reject { background: #fce4ec; color: #dc2626; }
.footer-end { background: #fef3c7; color: #b45309; }
.footer-spawn { background: #ede9fe; color: #7c3aed; }
</style>

View File

@ -103,17 +103,21 @@
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button></view>
</template>
<template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交 · 裂变派发</text>
<view v-for="(branch, idx) in transferForm.branches" :key="idx" class="branch-item">
<view class="branch-header"><text class="branch-label">分支 {{ idx + 1 }}</text><text v-if="transferForm.branches.length > 1" class="branch-del" @tap="removeBranch(idx)">🗑 删除</text></view>
<view class="form-item"><text class="form-label">工序 <text class="required">*</text></text><picker mode="selector" :range="processOptions" @change="(e) => onBranchProcessChange(idx, e)"><view class="picker-value"><text :class="branch.task_name ? '' : 'picker-placeholder'">{{ branch.task_name || '请选择工序' }}</text><text class="picker-arrow"></text></view></picker></view>
<view v-if="!isBranchWarehouse(idx)" class="form-item"><text class="form-label">接收人 <text class="required">*</text></text><picker mode="selector" :range="userOptions" range-key="name" @change="(e) => onBranchUserChange(idx, e)"><view class="picker-value"><text :class="branchUserName(idx) ? '' : 'picker-placeholder'">{{ branchUserName(idx) || '请选择接收人' }}</text><text class="picker-arrow"></text></view></picker></view>
<view v-if="isBranchWarehouse(idx)" class="warehouse-hint">📦 入库分支 直接归档无需指定接收人</view>
</view>
<button class="btn-add-branch" @tap="addBranch"> 添加并行分支</button>
<text class="popup-title">完工转交</text>
<view class="form-item"><text class="form-label">下一道工序 <text class="required">*</text></text><picker mode="selector" :range="processOptions" @change="onTransferProcessChange"><view class="picker-value"><text :class="transferForm.next_task_name ? '' : 'picker-placeholder'">{{ transferForm.next_task_name || '请选择工序' }}</text><text class="picker-arrow"></text></view></picker></view>
<view v-if="!transferForm.isWarehouse" class="form-item"><text class="form-label">接收人 <text class="required">*</text></text><picker mode="selector" :range="userOptions" range-key="name" @change="onTransferUserChange"><view class="picker-value"><text :class="transferUserName ? '' : 'picker-placeholder'">{{ transferUserName || '请选择接收人' }}</text><text class="picker-arrow"></text></view></picker></view>
<view v-if="transferForm.isWarehouse" class="warehouse-hint">📦 入库 直接归档无需指定接收人</view>
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" style="margin-top:12px;" />
<view v-if="transferForm.branches.some(b => b.task_name)" class="preview-hint">将创建 {{ transferForm.branches.length }} 个并行任务:<text v-for="(b, i) in transferForm.branches" :key="i" style="display:block;margin-top:2px;">{{ i + 1 }}. {{ b.task_name || '' }} {{ branchUserName(i) || (isBranchWarehouse(i) ? '仓库' : '未选') }}</text></view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferForm.branches.some(b => b.task_name) || !canSubmitTransfer" @tap="doTransfer">{{ actionLoading ? '提交中...' : '确认转交 (' + transferForm.branches.length + '分支)' }}</button></view>
<view v-if="transferForm.next_task_name" class="preview-hint">{{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建 1 ' + transferForm.next_task_name + '任务指派给 ' + (transferUserName || '') }}</view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferForm.next_task_name || (!transferForm.isWarehouse && !transferForm.selectedUserId)" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></view>
</template>
<template v-if="actionPopup.type === 'spawn'">
<text class="popup-title"> 派发协助分支</text>
<text class="popup-hint">为当前任务创建一个并行协助任务当前任务保持进行中</text>
<view class="form-item"><text class="form-label">工序名称 <text class="required">*</text></text><picker mode="selector" :range="spawnProcessOptions" @change="onSpawnProcessChange"><view class="picker-value"><text :class="spawnForm.task_name ? '' : 'picker-placeholder'">{{ spawnForm.task_name || '请选择工序' }}</text><text class="picker-arrow"></text></view></picker></view>
<view class="form-item"><text class="form-label">接收人 <text class="required">*</text></text><picker mode="selector" :range="userOptions" range-key="name" @change="onSpawnUserChange"><view class="picker-value"><text :class="spawnUserName ? '' : 'picker-placeholder'">{{ spawnUserName || '请选择接收人' }}</text><text class="picker-arrow"></text></view></picker></view>
<input v-model="spawnForm.remark" class="popup-input" placeholder="派发备注(选填)" />
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary btn-spawn" :disabled="actionLoading || !spawnForm.task_name || !spawnForm.assignee_id" @tap="doSpawn">{{ actionLoading ? '提交中...' : '确认派发' }}</button></view>
</template>
</view>
</view>
@ -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; } },
},
};
</script>