From 71e660b42887c07dbc2c1106c4ba8fa3a4c04502 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Tue, 15 Sep 2026 10:57:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A9=B3=E5=9B=9E=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=80=89=E5=A1=AB=EF=BC=8C=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0/=E8=BD=AC=E4=BA=A4=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务调整:车间驳回不必须拍照(编号错误、选错工序等场景无需照片举证)。 - schemas/task.py: TaskRejectRequest.images 由必填(min_length=1)改为 default_factory=list,保留 max_length=9 与 URL 总长保护;reason 增加 mode="before" 的 strip 校验器,堵住纯空格凑长度绕过 min_length 的口子 (顺带保证落库的 reject_reason 不带首尾空白) - task_service.py: 驳回时把原因+图片写入 TaskRecord 挂到被驳回任务下, 修复前端时间线看不到驳回详情的问题;转交时把交接备注也挂一条记录到 下家任务,补齐接手人的上下文视角 - task_service.py: create_task / receive_task 写入 overall_status 后调用 sync_product_status 对齐 status 字段 --- backend/app/schemas/task.py | 25 +++++++++++- backend/app/services/task_service.py | 59 +++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index ea1231b..a171e4b 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -49,8 +49,31 @@ class SubtaskCreate(BaseModel): class TaskRejectRequest(BaseModel): - """品质驳回请求""" + """品质驳回请求 — 驳回原因必填;异常图片【选填】 + (编号错误、工序选错等场景无需拍照举证,故不强制传图)""" reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)") + images: list[str] = Field( + default_factory=list, max_length=9, + description="异常图片 URL 列表(选填,可传空数组,最多 9 张)", + ) + + @field_validator("reason", mode="before") + @classmethod + def _strip_reason(cls, v): + """先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。 + 顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。 + 非字符串原样返回,让 Pydantic 抛出正常的类型错误。""" + return v.strip() if isinstance(v, str) else v + + @field_validator("images") + @classmethod + def _validate_images(cls, v: list[str]) -> list[str]: + """剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验, + 避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。""" + urls = [u.strip() for u in v if u and u.strip()] + if sum(len(u) for u in urls) > 3500: + raise ValueError("异常图片 URL 总长度超限,请减少图片数量") + return urls class TaskTransferBranch(BaseModel): diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 99a0ec5..12785a1 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -1,5 +1,6 @@ """任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务""" from __future__ import annotations +import json import uuid from fastapi import HTTPException, status from sqlalchemy import select, delete @@ -16,6 +17,7 @@ from app.core.lifecycle import ( is_step_allowed, phase_label, resolve_phase_for_step, + sync_product_status, ) from app.models.product import Product from app.models.task_log import TaskLog @@ -380,6 +382,9 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: _enforce_step_isolation(product, data.task_name, action="创建任务") if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")): product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name + # 🔧 双字段同步:overall_status 改动后必须对齐 status, + # 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径 + sync_product_status(product) # 派发给人 → 产品离开仓库 if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE: product.current_location_id = data.assignee_id @@ -681,6 +686,9 @@ async def receive_task( not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY") ): product.overall_status = task_name # 只主线任务同步宏观状态 + # 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留 + # (如整体已是「发货测试」而 status 还停在 OUTBOUND) + sync_product_status(product) await db.commit() await db.refresh(task) @@ -704,8 +712,9 @@ async def reject_task( 2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)。 - 若有父任务:返工任务分配给父任务的 assignee_id。 - 若无父任务(顶层任务):返工任务分配给当前任务自己的 assignee_id。 - 3. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。 - 4. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。 + 3. 把驳回原因 + 异常图片写入 TaskRecord,挂到被驳回的任务下(前端时间线可见)。 + 4. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。 + 5. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。 """ task = await _get_task_or_404(db, task_id) @@ -761,7 +770,16 @@ async def reject_task( if not rework_assignee_id: rework_assignee_id = task.assignee_id - # --- 3. 创建返工任务 --- + # --- 3. 写入驳回记录(原因 + 异常图片)→ 修复前端时间线缺失 --- + # 图片为选填(编号错误等场景可不传),空数组落库为 "[]",前端时间线照常渲染 + db.add(TaskRecord( + task_id=task.id, + remark=f"[品质驳回] {request.reason}(返工任务已派发给 {rework_assignee_id or '未指派'})", + images=json.dumps(request.images, ensure_ascii=False), + created_at=now, + )) + + # --- 4. 创建返工任务 --- rework_task = Task( product_id=task.product_id, parent_task_id=task.parent_task_id, # 与原任务同级 @@ -955,6 +973,16 @@ async def transfer_task( remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}", task_assignee_id=nt.assignee_id, ) + # 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上。 + # 原先留言只落在「已完工的上游任务记录」+「新任务的 Task.remark 字段」里, + # 接手人打开自己任务的「操作日志」只能看到 [接收],看不到交接上下文, + # 转交备注的协作意义就丢了。这里补齐下游视角。 + if request.note and request.note.strip(): + db.add(TaskRecord( + task_id=nt.id, + remark=f"[上一工序转交留言]:{request.note.strip()}", + images="[]", + )) # 🔔 通知:新任务派发 if nt.assignee_id: db.add(Notification( @@ -970,12 +998,13 @@ async def transfer_task( if has_warehouse and not real_branches: product.current_location_id = VIRTUAL_WAREHOUSE product.overall_status = "待仓库收货" - # 双字段同步:完工转交入库 → 产品标记为已完成(MOM 扫码实收后才变已入库) - product.status = "COMPLETED" elif real_branches: product.current_location_id = real_branches[0][1] if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"): product.overall_status = real_branches[0][0] + # 🔧 双字段同步:两个分支统一在这里对齐一次(原先只有入库分支硬编码 + # product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND) + sync_product_status(product) # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务 if not real_branches and not has_warehouse: @@ -1057,6 +1086,17 @@ async def complete_task( remark=request.remark or f"完成任务: {task.task_name}", task_assignee_id=task.assignee_id, ) + # 🔧 完工留痕(与 transfer_task 同口径):备注要写成 TaskRecord, + # 否则「操作日志」时间线只读 TaskRecord、看不到 TaskLog,交接内容就丢了。 + _will_transfer = bool(request.next_task_name and request.next_assignee_id) + db.add(TaskRecord( + task_id=task.id, + remark=( + request.remark + or ("[完工转交] 移交下一工序" if _will_transfer else "[完工] 任务已完成") + ) + _admin_proxy_note(request.operator_id, task.assignee_id), + images="[]", + )) # --- 4. 可选:创建下一步任务(转交) --- next_task = None @@ -1091,6 +1131,15 @@ async def complete_task( remark=f"由任务「{task.task_name}」完成后转交创建", task_assignee_id=next_task.assignee_id, ) + # 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上, + # 让接手人打开自己的「操作日志」就能看到交接上下文 + # (与 transfer_task 同口径,保证两条转交路径行为一致) + if request.remark and request.remark.strip(): + db.add(TaskRecord( + task_id=next_task.id, + remark=f"[上一工序转交留言]:{request.remark.strip()}", + images="[]", + )) # 🔧 位置回溯:老接口也触发(父任务优先) await _recalc_product_location(db, task.product_id, task_id)