diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py
index a171e4b..45ee5f5 100644
--- a/backend/app/schemas/task.py
+++ b/backend/app/schemas/task.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
# ============================================================
@@ -79,7 +79,13 @@ class TaskRejectRequest(BaseModel):
class TaskTransferBranch(BaseModel):
"""裂变分支"""
task_name: str = Field(..., max_length=200, description="工序名称")
- assignees: list[str] = Field(..., min_length=1, description="接收人列表")
+ assignees: list[str] = Field(
+ default_factory=list, min_length=0,
+ description=(
+ "接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
+ "空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
+ ),
+ )
class TaskTransferRequest(BaseModel):
@@ -88,6 +94,27 @@ class TaskTransferRequest(BaseModel):
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
note: str | None = Field(None, description="交接备注")
+ finish_directly: bool = Field(
+ False,
+ description=(
+ "直接完结:闭环当前任务但【不产生任何下游任务】,"
+ "产品 overall_status 保持原样(不入库 → 不会变成「待仓库收货」)。"
+ "置 True 时忽略 next_tasks / next_assignees。"
+ ),
+ )
+
+ @model_validator(mode="after")
+ def _reject_silent_empty_branch(self):
+ """空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
+ 是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
+ if self.finish_directly:
+ return self
+ if any(not b.assignees for b in (self.next_tasks or [])):
+ raise ValueError(
+ "分支 assignees 不能为空;若意图是「直接完结该任务」,"
+ "请改为传 finish_directly=true"
+ )
+ return self
class TaskRecordCreate(BaseModel):
diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py
index 12785a1..3fe77eb 100644
--- a/backend/app/services/task_service.py
+++ b/backend/app/services/task_service.py
@@ -844,6 +844,12 @@ async def transfer_task(
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。
- 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。
+ 动作 2'(直接完结 finish_directly=True):
+ - 不解析任何下家,分支强制为空,直接落入下方"无下家"兜底分支。
+ - 用于「售后返厂直接发走 / 半成品被提走」这类**无需入库**的收官场景:
+ 工人不必再借道「入库(virtual_warehouse)」来关闭任务,
+ 从而避免把产品误标成「待仓库收货」并卡住 MOM 对账。
+
裂变逻辑:
- 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。
- 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。
@@ -879,6 +885,16 @@ async def transfer_task(
now = get_beijing_time()
# --- 动作 1:闭环当前节点 ---
+ # 🔧 直接完结走独立文案:action_type 沿用 "complete"(不新增取值,避免扰动现有
+ # 看板的完成数口径),仅在 remark 里区分,审计时可按文本筛出「直接完结」。
+ finish_directly = bool(request.finish_directly)
+ if finish_directly:
+ log_remark = request.note or f"直接完结任务「{task.task_name}」(无下游,不入库)"
+ record_remark = request.note or "[直接完结] 无下游任务,产品宏观状态保持不变"
+ else:
+ log_remark = request.note or f"完成任务「{task.task_name}」,转交至下一道工序"
+ record_remark = request.note or "[完工转交] 移交下一工序"
+
task.status = TASK_STATUS_COMPLETED
task.completed_at = now
@@ -886,11 +902,11 @@ async def transfer_task(
db, task_id,
action_type="complete",
operator_id=operator_id,
- remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
+ remark=log_remark,
task_assignee_id=task.assignee_id,
)
db.add(TaskRecord(task_id=task.id,
- remark=(request.note or f"[完工转交] 移交下一工序") + _admin_proxy_note(operator_id, task.assignee_id),
+ remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id),
images="[]"))
# --- 动作 2:解析下家 & 裂变 ---
@@ -903,11 +919,16 @@ async def transfer_task(
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期。
# 必须在阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
- if product:
+ # ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
+ # 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
+ if product and not finish_directly:
await _mark_after_sales_if_reactivated(db, task.product_id)
# 兼容新旧格式
- if request.next_tasks:
+ # 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。
+ if finish_directly:
+ branches: list[tuple[str | None, str]] = []
+ elif request.next_tasks:
branches = [
(b.task_name, a)
for b in request.next_tasks
@@ -1007,6 +1028,9 @@ async def transfer_task(
sync_product_status(product)
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
+ # 「直接完结」(finish_directly) 与旧版空分支都落到这里:
+ # overall_status 在上面两个分支里都没被赋值,故原样保留
+ # (已出库 / 在库 / 售后维修… 不会被改写成「待仓库收货」)。
if not real_branches and not has_warehouse:
await _recalc_product_location(db, task.product_id, task_id)
@@ -1019,19 +1043,25 @@ async def transfer_task(
await db.refresh(nt)
created_task_responses.append(_to_response(nt))
- assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
- location_info = ""
- if has_warehouse:
- location_info = ",产品已入库(virtual_warehouse)"
+ # 🔧 直接完结没有下家,不能套用"已创建 N 个任务(接收人: 仓库)"的模板
+ # (否则会拼出「已创建 0 个下一道工序任务「None」(接收人: 仓库)」这种误导文案)
+ if finish_directly:
+ message = f"任务「{task.task_name}」已直接完结(无下游任务,产品状态保持不变)"
+ else:
+ assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
+ location_info = ""
+ if has_warehouse:
+ location_info = ",产品已入库(virtual_warehouse)"
+ message = (
+ f"任务「{task.task_name}」已完成,"
+ f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
+ f"(接收人: {assignee_list}){location_info}"
+ )
return TaskTransferResponse(
completed_task=_to_response(refreshed_task),
created_tasks=created_task_responses,
- message=(
- f"任务「{task.task_name}」已完成,"
- f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
- f"(接收人: {assignee_list}){location_info}"
- ),
+ message=message,
)
diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue
index 744a712..94e160a 100644
--- a/track-uniapp/src/pages/scan/detail.vue
+++ b/track-uniapp/src/pages/scan/detail.vue
@@ -148,7 +148,7 @@
- 接收人 *
+ 接收人 / 处理方式 *
或
📦 入库 (virtual_warehouse)
+ 🏁 直接完结 (不入库)
+
交接备注 *
- {{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建新任务指派给 ' + (transferUserName || '—') }}
-
+ {{ transferPreview }}
+
@@ -250,7 +252,7 @@ export default {
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
rejectForm: { images: [], pendingCount: 0 },
- transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
+ transferForm: { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" },
spawnForm: { assignee_id: "", remark: "" },
// 🛡️ 双重确认倒计时:避免误触
confirming: "", // 当前倒计时中的操作 key('' = 无)
@@ -271,6 +273,10 @@ export default {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
+ // 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
+ transferMode() { const f = this.transferForm; if (f.isFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
+ transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
+ transferPreview() { return { direct: '任务将直接完结,不创建下游任务;产品状态保持不变,不会进入「待仓库收货」', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
@@ -488,7 +494,7 @@ export default {
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
- this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
+ this.transferForm = { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" };
this.spawnForm = { assignee_id: "", remark: "" };
},
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
@@ -598,9 +604,28 @@ export default {
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
async doReject() { if (this.isUploading) return; 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(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } },
// 转交 — 互斥选择
- selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; },
- toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; },
- async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, this.transferForm.isWarehouse ? "入库" : "转交"); } finally { this.actionLoading = false; } },
+ selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; this.transferForm.isFinishDirect = false; },
+ toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) { this.transferForm.selectedUserId = ""; this.transferForm.isFinishDirect = false; } },
+ // 🏁 直接完结:与前两者互斥。不发往仓库 → 后端落入"无下家"分支,overall_status 原样保留
+ toggleFinishDirect() { this.transferForm.isFinishDirect = !this.transferForm.isFinishDirect; if (this.transferForm.isFinishDirect) { this.transferForm.selectedUserId = ""; this.transferForm.isWarehouse = false; } },
+ async doTransfer() {
+ this.actionLoading = true;
+ const { isWarehouse, isFinishDirect } = this.transferForm;
+ try {
+ const note = this.transferForm.note.trim() || undefined;
+ // 🏁 直接完结:next_tasks 留空 + finish_directly=true,
+ // 后端据此跳过分支解析,只闭环任务、不改产品宏观状态(不会变成"待仓库收货")
+ const payload = isFinishDirect
+ ? { next_tasks: [], finish_directly: true, note }
+ : { next_tasks: [{ task_name: isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认", assignees: isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId] }], note };
+ await post(`/tasks/${this.actionPopup.task.id}/transfer`, payload);
+ uni.showToast({ title: isFinishDirect ? "已直接完结" : (isWarehouse ? "已入库" : "转交成功"), icon: "success" });
+ this.closeActionPopup();
+ this.doQuery(this.product.serial_number);
+ } catch (e) {
+ this.handleNetworkFailure(e, isFinishDirect ? "直接完结" : (isWarehouse ? "入库" : "转交"));
+ } finally { this.actionLoading = false; }
+ },
// 派发协助分支
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: "待确认", 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 (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } },
// 💬 留言板