并发模型: 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 from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, Query, Body from fastapi import APIRouter, Depends, Query, Body
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db 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) 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) # 核心业务 1确认接收 (PENDING → WIP)
# ============================================================ # ============================================================

View File

@ -236,6 +236,9 @@ async def end_task(
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
# 校验:必须等待所有协助分支完成
await _check_children_done(db, task_id)
if task.status == TASK_STATUS_COMPLETED: if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(status_code=409, detail="此分支已经结束") raise HTTPException(status_code=409, detail="此分支已经结束")
if task.status == TASK_STATUS_PENDING: if task.status == TASK_STATUS_PENDING:
@ -253,6 +256,62 @@ async def end_task(
return _to_response(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) # 核心业务 1确认接收 (PENDING → WIP)
# ============================================================ # ============================================================
@ -429,6 +488,9 @@ async def transfer_task(
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
# 校验:必须等待所有协助分支完成
await _check_children_done(db, task_id)
# 校验:不能重复完成 # 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED: if task.status == TASK_STATUS_COMPLETED:
raise HTTPException( raise HTTPException(

View File

@ -78,6 +78,8 @@
@tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交</button> @tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record" <button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
@tap="$emit('action', { task: lockedTask, type: 'record' })">📝 记录/拍照</button> @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" <button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🏁 结束分支</button> @tap="$emit('action', { task: lockedTask, type: 'end' })">🏁 结束分支</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive" <button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
@ -189,4 +191,5 @@ export default {
.footer-receive { background: #dbeafe; color: #1d4ed8; } .footer-receive { background: #dbeafe; color: #1d4ed8; }
.footer-reject { background: #fce4ec; color: #dc2626; } .footer-reject { background: #fce4ec; color: #dc2626; }
.footer-end { background: #fef3c7; color: #b45309; } .footer-end { background: #fef3c7; color: #b45309; }
.footer-spawn { background: #ede9fe; color: #7c3aed; }
</style> </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> <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>
<template v-if="actionPopup.type === 'transfer'"> <template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交 · 裂变派发</text> <text class="popup-title">完工转交</text>
<view v-for="(branch, idx) in transferForm.branches" :key="idx" class="branch-item"> <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 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 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 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="transferForm.isWarehouse" class="warehouse-hint">📦 入库 直接归档无需指定接收人</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>
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" style="margin-top:12px;" /> <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 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.branches.some(b => b.task_name) || !canSubmitTransfer" @tap="doTransfer">{{ actionLoading ? '提交中...' : '确认转交 (' + transferForm.branches.length + '分支)' }}</button></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> </template>
</view> </view>
</view> </view>
@ -141,13 +145,16 @@ export default {
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree", currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree",
processOptions: [], userOptions: [], processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", 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: { computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); }, 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; }, 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() { 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; }; 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; return this.product ? find(this.product.task_tree) : false;
@ -186,8 +193,10 @@ export default {
if (type === "record") { this.openRecordPopup(task); return; } if (type === "record") { this.openRecordPopup(task); return; }
if (type === "deleteRecord") { this.doDeleteRecord(record); return; } if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
if (type === "end") { this.confirmEndBranch(task); 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(); } } 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 = { branches: [{ task_name: "", assignee_id: "" }], note: "" }; 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}` }); }, 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 {} }, 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 }; }, 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 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; } }, 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); }, 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 = ""; },
onBranchProcessChange(idx, e) { this.transferForm.branches[idx].task_name = this.processOptions[e.detail.value]; this.transferForm.branches[idx].assignee_id = ""; }, onTransferUserChange(e) { const user = this.userOptions[e.detail.value]; if (user) this.transferForm.selectedUserId = user.id; },
onBranchUserChange(idx, e) { const user = this.userOptions[e.detail.value]; if (user) this.transferForm.branches[idx].assignee_id = 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; } },
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 : ""; }, onSpawnProcessChange(e) { this.spawnForm.task_name = this.spawnProcessOptions[e.detail.value]; },
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; } }, 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> </script>