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