"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务""" from __future__ import annotations import json import uuid from fastapi import HTTPException, status from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT from app.core.time_utils import get_beijing_time from app.core.lifecycle import ( AFTER_SALES_REPAIR_STEPS, LIFECYCLE_AFTER_SALES, PRODUCTION_ONLY_STEPS, allowed_steps, 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 from app.core.roles import ADMIN_ROLES from app.schemas.task import ( TaskCreate, TaskUpdate, TaskCompleteRequest, TaskRejectRequest, TaskTransferRequest, SubtaskCreate, TaskRecordCreate, TaskRecordResponse, TaskResponse, TaskCompleteResponse, TaskTransferResponse, TaskSummaryResponse, TaskListResponse, ) # 特殊位置常量 VIRTUAL_WAREHOUSE = "virtual_warehouse" # 管理员/主管角色白名单 — 拥有上帝视角操作权限 # 定义已收敛到 app.core.roles(单一事实来源);本模块继续以同名导出, # 兼容 products.py 等处 `from app.services.task_service import ADMIN_ROLES` 的既有引用 async def _recalc_product_location( db: AsyncSession, product_id: uuid.UUID, completed_task_id: uuid.UUID | None = None, ) -> None: """ 任务完工/结束时触发:只跟随主干任务(主分支),无视协助分支。 主干任务定义: parent_task_id IS NULL OR task_type IN ('TRANSFER', 'RECOVERY') 优先级: WIP > PENDING > COMPLETED/ARCHIVED > None """ from sqlalchemy import select as sa_select, case as sa_case, or_ as sa_or_ product_result = await db.execute( sa_select(Product).where(Product.id == product_id) ) product = product_result.scalar_one_or_none() if not product: return # ── 只查主干任务: parent_task_id IS NULL 或 task_type IN (TRANSFER, RECOVERY) ── stmt = ( sa_select(Task) .where( Task.product_id == product_id, sa_or_( Task.parent_task_id.is_(None), Task.task_type.in_(["TRANSFER", "RECOVERY"]), ), ) .order_by( # 优先级排序: WIP=3, PENDING=2, COMPLETED=1, ARCHIVED=1, else=0 sa_case( (Task.status == TASK_STATUS_WIP, 3), (Task.status == TASK_STATUS_PENDING, 2), (Task.status == TASK_STATUS_COMPLETED, 1), (Task.status == TASK_STATUS_ARCHIVED, 1), else_=0, ).desc(), Task.created_at.desc(), ) .limit(1) ) result = await db.execute(stmt) main_task = result.scalar_one_or_none() # 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级: # 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。 has_active_main = main_task is not None and main_task.status in ( TASK_STATUS_WIP, TASK_STATUS_PENDING, ) new_location = main_task.assignee_id if main_task else None # 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。 # 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse), # 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。 # ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。 if product.overall_status == "已出库" and not has_active_main: new_location = None if product.current_location_id != new_location: product.current_location_id = new_location await db.flush() # 唯一的落盘点 # 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。 # 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被 # 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库 # (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。 # # ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被 # 重新派活,宏观状态理应随工序更新。 PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库") def _is_physical_terminal(overall: str | None) -> bool: """该宏观状态是否为「绝对物理终态」(不应被任务名覆写)""" return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL async def _mark_after_sales_if_reactivated( db: AsyncSession, product_id: uuid.UUID, steps: str | list[str | None] | None = None, ) -> bool: """出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。 判定依据(必须同时满足): 1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND') 2. 本次接到的工序是明确修机指令(售后维修) ⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的 AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。 该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把 生产阶段的「发货测试」与回流后的「售后维修」区分开。 调用时机必须早于调用方改写 overall_status,否则会漏判。 :param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。 返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中 identity map,不产生额外查询。 """ product = await db.get(Product, product_id) if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES: return False names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s] if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names): return False is_outbound = ( product.overall_status == "已出库" or (product.status or "").upper() == "OUTBOUND" ) if not is_outbound: return False product.lifecycle_phase = LIFECYCLE_AFTER_SALES await db.flush() return True def _enforce_step_isolation( product: Product, step: str | None, *, action: str, ) -> None: """选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。 行为两步: 1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。 这是「无历史记录的老设备」进入售后阶段的入口。 2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、 请求被伪造时,仍在此拦下。 必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备 已处于 AFTER_SALES,自然排不回前期工序。 注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行, 否则建单 / 转交入库整条链路会被卡死。 """ phase = resolve_phase_for_step(product.lifecycle_phase, step) if not is_step_allowed(phase, step): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( f"产品当前处于{phase_label(phase)},{action}不允许使用工序「{step}」。" f"该阶段可选:{'、'.join(allowed_steps(phase))}" ), ) if phase != product.lifecycle_phase: product.lifecycle_phase = phase def _reject_cross_phase_steps( phase: str | None, steps: list[str | None], *, action: str, ) -> None: """跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。 转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用 白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。 因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。 「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。 """ if (phase or "") != LIFECYCLE_AFTER_SALES: return bad = sorted({ s.strip() for s in steps if s and s.strip() in PRODUCTION_ONLY_STEPS }) if bad: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)},{action}不允许使用前期工序:" f"{'、'.join(bad)}。该阶段可选:{'、'.join(allowed_steps(LIFECYCLE_AFTER_SALES))}" ), ) def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None: """权限校验:本人 或 管理员/主管 可操作""" if operator_role and operator_role in ADMIN_ROLES: return # 上帝视角,直接放行 if operator_id and task_assignee_id and operator_id != task_assignee_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"您无权操作此任务,当前任务负责人为 {task_assignee_id}", ) # ============================================================ # 内部辅助函数 # ============================================================ async def _get_task_or_404(db: AsyncSession, task_id: uuid.UUID) -> Task: """获取任务,不存在则 404""" result = await db.execute( select(Task) .options( selectinload(Task.child_tasks), selectinload(Task.parent_task), selectinload(Task.product), selectinload(Task.records), ) .where(Task.id == task_id) ) task = result.scalar_one_or_none() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"任务不存在: {task_id}", ) return task async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID) -> Task: """使用 PostgreSQL Recursive CTE 一次性加载任务及其所有子孙任务(消除 N+1)""" from app.services.task_tree_loader import load_task_tree_by_root return await load_task_tree_by_root(db, task_id) def _to_flat_response(task: Task) -> TaskResponse: """扁平序列化,不递归 children(避免 MissingGreenlet)""" product_sn = "" product_material = "" try: if task.product: product_sn = task.product.serial_number or "" product_material = (task.product.material_name or task.product.material_id or "") except Exception: pass return TaskResponse( id=task.id, product_id=task.product_id, product_sn=product_sn, product_material=product_material, parent_task_id=task.parent_task_id, task_name=task.task_name, assignee_id=task.assignee_id, status=task.status, notify_parent_on_complete=task.notify_parent_on_complete, is_rework=task.is_rework, task_type=task.task_type, remark=task.remark, reject_reason=task.reject_reason, received_at=task.received_at, completed_at=task.completed_at, created_at=task.created_at, child_tasks=[], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], ) def _to_response(task: Task) -> TaskResponse: """将 Task ORM 对象转为递归 TaskResponse""" product_sn = "" product_material = "" try: if task.product: product_sn = task.product.serial_number or "" product_material = (task.product.material_name or task.product.material_id or "") except Exception: pass return TaskResponse( id=task.id, product_id=task.product_id, product_sn=product_sn, product_material=product_material, parent_task_id=task.parent_task_id, task_name=task.task_name, assignee_id=task.assignee_id, status=task.status, notify_parent_on_complete=task.notify_parent_on_complete, is_rework=task.is_rework, task_type=task.task_type, remark=task.remark, reject_reason=task.reject_reason, received_at=task.received_at, completed_at=task.completed_at, created_at=task.created_at, child_tasks=[_to_response(c) for c in task.child_tasks], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], ) async def _check_all_critical_children_completed( db: AsyncSession, task_id: uuid.UUID ) -> tuple[bool, list[str]]: """ 检查指定任务下所有 notify_parent_on_complete=True 的子任务是否都已完成。 返回 (是否全部完成, 未完成的子任务名称列表)。 """ result = await db.execute( select(Task).where( Task.parent_task_id == task_id, Task.notify_parent_on_complete.is_(True), ) ) critical_children = result.scalars().all() incomplete = [ child.task_name for child in critical_children if child.status != TASK_STATUS_COMPLETED ] return len(incomplete) == 0, incomplete def _admin_proxy_note(operator_id: str | None, task_assignee_id: str | None) -> str: """检测管理员代办 → 返回审计标记后缀""" if operator_id and task_assignee_id and operator_id != task_assignee_id: return f" [管理员 {operator_id} 代办]" return "" async def _create_task_log( db: AsyncSession, task_id: uuid.UUID, action_type: str, operator_id: str | None = None, remark: str | None = None, task_assignee_id: str | None = None, ) -> TaskLog: """创建任务操作日志。自动检测管理员代办并拼接审计标记。""" final_remark = (remark or "") + _admin_proxy_note(operator_id, task_assignee_id) log = TaskLog( task_id=task_id, operator_id=operator_id, action_type=action_type, remark=final_remark.strip() or None, ) db.add(log) return log # ============================================================ # 公开 API — 查询 # ============================================================ async def get_task(db: AsyncSession, task_id: uuid.UUID) -> TaskResponse: """获取任务详情 — 递归包含所有子任务""" task = await _get_task_with_children_recursive(db, task_id) return _to_response(task) async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[TaskSummaryResponse]: """获取产品的顶层任务列表""" result = await db.execute( select(Task) .where( Task.product_id == product_id, Task.parent_task_id.is_(None), ) .order_by(Task.created_at) ) tasks = result.scalars().all() return [TaskSummaryResponse.model_validate(t) for t in tasks] async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: """创建任务,并同步产品宏观状态""" task = Task(**data.model_dump()) db.add(task) # 同步产品宏观状态 + 当前位置 product_result = await db.execute(select(Product).where(Product.id == data.product_id)) product = product_result.scalar_one_or_none() if product: # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期 # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。 await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name) # 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参) _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")) and not _is_physical_terminal(product.overall_status) ): 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 await db.commit() await db.refresh(task) return _to_response(task) async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse: """更新任务""" task = await _get_task_or_404(db, task_id) update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(task, field, value) await db.commit() await db.refresh(task) return _to_response(task) async def get_all_tasks( db: AsyncSession, product_id: uuid.UUID | None = None, assignee_id: str | None = None, skip: int = 0, limit: int = 50 ) -> TaskListResponse: """获取任务列表,可按产品/负责人筛选""" filters = [] if product_id: filters.append(Task.product_id == product_id) if assignee_id: filters.append(Task.assignee_id == assignee_id) # 总数必须独立 COUNT:移动端「我的任务」用 total 判断 hasMore # (tasks.length < total),若 total 取当前页条数,首页满员时 # hasMore 恒为 false,列表永远停在第一页。 total = await db.scalar( select(func.count()).select_from(Task).where(*filters) ) or 0 stmt = ( select(Task) .options( selectinload(Task.records), selectinload(Task.product), ) .where(*filters) .offset(skip) .limit(limit) .order_by(Task.created_at.desc()) ) result = await db.execute(stmt) tasks = result.scalars().all() # 返回扁平列表(不递归 children,避免 MissingGreenlet) flat_tasks = [_to_flat_response(t) for t in tasks] return TaskListResponse(tasks=flat_tasks, total=total) # ============================================================ # 核心业务 0:结束分支(终止当前节点) # ============================================================ async def end_task( db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, operator_role: str | None = None, ) -> TaskResponse: """ 结束当前分支:标记任务为 COMPLETED,不创建下游任务。 用于工序已完结、无需转交下一人的场景。 """ task = await _get_task_or_404(db, task_id) # 权限校验:本人 或 管理员/主管 可结束 _check_permission(task.assignee_id, operator_id, operator_role) # 校验:仅 SPAWN 协助分支可以结束,主分支(TRANSFER/RECOVERY)不能通过此接口终止 if not task.parent_task_id: raise HTTPException(status_code=409, detail="根任务无法结束,请使用完工转交") if task.task_type != "SPAWN": raise HTTPException(status_code=409, detail="仅协助分支可以结束,主分支请使用完工转交") # 校验:必须等待所有协助分支完成 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: raise HTTPException(status_code=409, detail="请先接收任务再结束分支") now = get_beijing_time() task.status = TASK_STATUS_COMPLETED task.completed_at = now await _create_task_log(db, task_id, action_type="end", operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)", task_assignee_id=task.assignee_id) # 🔧 位置回溯:分支结束后优先回溯到父任务负责人 await _recalc_product_location(db, task.product_id, task.id) await db.commit() await db.refresh(task) return _to_response(task) # ============================================================ # 核心业务 0.3:撤回转交 # ============================================================ async def recall_task( db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, operator_role: str | None = None, ) -> TaskResponse: """撤回 PENDING 转交:标记为 CANCELED,以被撤回节点为父生成接力新任务给操作人。 权限校验: - 管理员(SUPER_ADMIN / SUPERVISOR):直接放行 - 操作人 必须等于 上游任务的负责人(谁发出的谁才能撤回),否则 403 """ task = await _get_task_or_404(db, task_id) if task.status != TASK_STATUS_PENDING: raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回") # ── 权限校验:谁发出的谁才能撤回 ── if not (operator_role and operator_role in ADMIN_ROLES): # 查找上游任务的负责人(发出者) upstream_assignee: str | None = None if task.parent_task_id: parent_result = await db.execute( select(Task).where(Task.id == task.parent_task_id) ) parent_task = parent_result.scalar_one_or_none() if parent_task: upstream_assignee = parent_task.assignee_id else: # 无父任务:从任务日志追溯创建人 log_result = await db.execute( select(TaskLog).where( TaskLog.task_id == task_id, TaskLog.action_type == "create", ).order_by(TaskLog.created_at.asc()).limit(1) ) create_log = log_result.scalar_one_or_none() if create_log: upstream_assignee = create_log.operator_id # 如果找不到上游负责人,或者操作人不等于上游负责人 → 403 if not upstream_assignee or operator_id != upstream_assignee: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="您不是该任务的发起人,无法撤回", ) now = get_beijing_time() # 1. 废掉当前待接收任务 task.status = TASK_STATUS_CANCELED task.completed_at = now await _create_task_log(db, task_id, action_type="recall", operator_id=operator_id, remark=f"撤回转交「{task.task_name}」→ {task.assignee_id}", task_assignee_id=task.assignee_id) db.add(TaskRecord(task_id=task.id, remark=f"[撤回] 转交至 {task.assignee_id} 已撤回", images="[]")) # 2. 生成接力新任务(以撤回节点为父,还给操作人) recovery = Task( product_id=task.product_id, parent_task_id=task.id, task_name=task.task_name, assignee_id=operator_id, status=TASK_STATUS_WIP, task_type="RECOVERY", notify_parent_on_complete=False, is_rework=False, remark=f"撤回「{task.task_name}」后重新接手", ) db.add(recovery) await db.flush() await _create_task_log(db, recovery.id, action_type="create", operator_id=operator_id, remark=f"撤回接力:撤回「{task.task_name}」→ {task.assignee_id} 后重新指派给 {operator_id}", task_assignee_id=recovery.assignee_id) db.add(TaskRecord(task_id=recovery.id, remark=f"[重新接手] 撤回转交后系统自动生成接力节点", images="[]")) # 3. 更新产品位置 product_result = await db.execute(select(Product).where(Product.id == task.product_id)) product = product_result.scalar_one_or_none() if product and operator_id: product.current_location_id = operator_id await db.commit() await db.refresh(recovery) return _to_response(recovery) # ============================================================ # 核心业务 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, task_type="SPAWN", notify_parent_on_complete=False, is_rework=False, remark=data.remark or None, ) db.add(child) await db.flush() # 协助分支不改变产品宏观状态(只有主分支影响 overall_status) db.add(TaskRecord(task_id=task.id, remark=data.remark or f"[派发协助] 分配给 {data.assignee_id}", images="[]")) await _create_task_log(db, child.id, action_type="create", operator_id=operator_id, remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})", task_assignee_id=child.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, TASK_STATUS_CANCELED, 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) # ============================================================ async def receive_task( db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, remark: str | None = None, task_name: str | None = None, operator_role: str | None = None, ) -> TaskResponse: """ 操作员确认接收任务。工人选定工序名称后接收。 校验:只有状态为 PENDING 的任务可接收。 动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。 """ task = await _get_task_or_404(db, task_id) # 权限校验:本人 或 管理员/主管 可操作 _check_permission(task.assignee_id, operator_id, operator_role) # 校验:只有 PENDING 状态可接收 if task.status != TASK_STATUS_PENDING: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"只有待接收(PENDING)状态的任务可接收,当前状态: {task.status}", ) now = get_beijing_time() task.status = TASK_STATUS_WIP task.received_at = now if remark: task.remark = remark if task_name: task.task_name = task_name await _create_task_log( db, task_id, action_type="receive", operator_id=operator_id, remark=remark or f"操作员确认接收任务「{task.task_name}」", task_assignee_id=task.assignee_id, ) db.add(TaskRecord(task_id=task.id, remark=(remark or f"[接收] 操作员已确认接收") + _admin_proxy_note(operator_id, task.assignee_id), images="[]")) # 接收时同步产品位置到接收人 + 宏观状态同步 product_result = await db.execute(select(Product).where(Product.id == task.product_id)) product = product_result.scalar_one_or_none() if product: # 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期 # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。 await _mark_after_sales_if_reactivated( db, task.product_id, task_name or task.task_name, ) # 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内 #(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」) _enforce_step_isolation(product, task_name, action="接收任务") if task.assignee_id: product.current_location_id = task.assignee_id # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— # 设备已是 已入库/在库/已出库 时禁止用任务名覆写。 # 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在 # 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看 # 活跃任务看不出问题(跨报表口径就此打架)。 if ( task_name and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")) and not _is_physical_terminal(product.overall_status) ): product.overall_status = task_name # 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留 # (如整体已是「发货测试」而 status 还停在 OUTBOUND) sync_product_status(product) await db.commit() await db.refresh(task) return _to_response(task) # ============================================================ # 核心业务 2:品质驳回 (→ REJECTED + 返工闭环) # ============================================================ async def reject_task( db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None, operator_role: str | None = None, ) -> TaskResponse: """ 品质驳回:将当前任务标记为 REJECTED,并自动创建返工任务给上一道工序负责人。 防呆闭环逻辑: 1. 将当前任务状态改为 REJECTED,记录 reject_reason 和 completed_at。 2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)。 - 若有父任务:返工任务分配给父任务的 assignee_id。 - 若无父任务(顶层任务):返工任务分配给当前任务自己的 assignee_id。 3. 把驳回原因 + 异常图片写入 TaskRecord,挂到被驳回的任务下(前端时间线可见)。 4. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。 5. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。 """ task = await _get_task_or_404(db, task_id) # 权限校验:本人 或 管理员/主管 可驳回 _check_permission(task.assignee_id, operator_id, operator_role) # 校验:不能重复驳回已完成/已驳回的任务 if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"任务状态为 {task.status},无法驳回", ) now = get_beijing_time() # --- 1. 标记当前任务为已驳回 --- task.status = TASK_STATUS_REJECTED task.reject_reason = request.reason task.completed_at = now await _create_task_log( db, task_id, action_type="reject", operator_id=operator_id, remark=f"品质驳回: {request.reason}", task_assignee_id=task.assignee_id, ) # --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) --- rework_assignee_id: str | None = None # 🚀 优先方式:从任务日志追溯创建人(准确记录是谁发起的转交) log_result = await db.execute( select(TaskLog).where( TaskLog.task_id == task_id, TaskLog.action_type == "create", ).order_by(TaskLog.created_at.asc()).limit(1) ) create_log = log_result.scalar_one_or_none() if create_log and create_log.operator_id: rework_assignee_id = create_log.operator_id # 兜底方式1:有父任务 → 返工给父任务的负责人 if not rework_assignee_id and task.parent_task_id: parent_result = await db.execute( select(Task).where(Task.id == task.parent_task_id) ) parent_task = parent_result.scalar_one_or_none() if parent_task: rework_assignee_id = parent_task.assignee_id # 兜底方式2:用当前任务的负责人 if not rework_assignee_id: rework_assignee_id = task.assignee_id # --- 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, # 与原任务同级 task_name=task.task_name, assignee_id=rework_assignee_id, status=TASK_STATUS_PENDING, task_type=task.task_type, # 🚀 继承被驳回任务的基因:主线→主线,协助→协助 notify_parent_on_complete=task.notify_parent_on_complete, is_rework=True, ) db.add(rework_task) await db.flush() await _create_task_log( db, rework_task.id, action_type="create", operator_id=operator_id, remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}", task_assignee_id=rework_task.assignee_id, ) # 🔔 通知:品质驳回 product_sn = "" try: product_result = await db.execute(select(Product).where(Product.id == task.product_id)) p = product_result.scalar_one_or_none() if p: product_sn = p.serial_number or "" except Exception: pass if rework_assignee_id: db.add(Notification( user_id=rework_assignee_id, title="🔴 品质驳回提醒", content=f"产品 [{product_sn}] 的「{task.task_name}」被驳回,原因: {request.reason}", type=NOTIFY_REJECT, task_id=rework_task.id, )) await db.commit() await db.refresh(task) return _to_response(task) # ============================================================ # 核心业务 3:完工并裂变转交 (→ COMPLETED + 裂变创建下家任务) # ============================================================ async def transfer_task( db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None, operator_role: str | None = None, ) -> TaskTransferResponse: """ 完工并裂变转交: 动作 1(闭环当前节点): - 将当前任务状态改为 COMPLETED,记录 completed_at。 动作 2(解析下家): - 遍历 next_assignees 列表。 - 如果包含 '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):单路转交也挂到同一父任务下。 - 否则(顶层单路转交):新任务与当前任务同级(parent_task_id = None)。 - 更新 Product 的 current_location_id 为对应的人员(非仓库)。 """ task = await _get_task_or_404(db, task_id) # 权限校验:本人 或 管理员/主管 可转交 _check_permission(task.assignee_id, operator_id, operator_role) # ── 🏁 直接完结:额外收紧为「仅管理员/主管」── # 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法: # 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作 # (不产生下游任务、产品落终态),必须默认拒绝。 # ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT; # operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。 if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅超管和主管有权限进行直接完结操作", ) # 校验:不能重复完成 if task.status == TASK_STATUS_COMPLETED: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"任务「{task.task_name}」已经完成(当前状态: {task.status}),请刷新页面", ) # 校验:PENDING 状态不允许转交(必须先接收) if task.status == TASK_STATUS_PENDING: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"任务「{task.task_name}」尚未接收(当前状态: PENDING),请先接收再转交", ) # 校验:卡点逻辑 — 检查关键子任务 all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id) if not all_done: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}", ) 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 await _create_task_log( db, task_id, action_type="complete", operator_id=operator_id, remark=log_remark, task_assignee_id=task.assignee_id, ) db.add(TaskRecord(task_id=task.id, remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id), images="[]")) # --- 动作 2:解析下家 & 裂变 --- # 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。 # (通知也需要 product_sn,原本在创建任务之后才查,提前不增加查询次数) product_result = await db.execute( select(Product).where(Product.id == task.product_id) ) product = product_result.scalar_one_or_none() # 兼容新旧格式 # 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。 if finish_directly: branches: list[tuple[str | None, str]] = [] elif request.next_tasks: branches = [ (b.task_name, a) for b in request.next_tasks for a in (b.assignees or []) ] else: branches = [ (request.next_task_name, a) for a in (request.next_assignees or []) ] has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches) real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE] # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。 # 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。 # (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。) # ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过: # 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。 # ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。 if product and not finish_directly: await _mark_after_sales_if_reactivated( db, task.product_id, [tn for tn, _ in real_branches], ) # 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。 # 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。 if product: _reject_cross_phase_steps( product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交", ) is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1) is_child_task = task.parent_task_id is not None created_tasks: list[Task] = [] for task_name, assignee_id in real_branches: # 🔧 动态推导 task_type:禁止分支任务转交被硬编码为 TRANSFER(否则 is_main 判定误升为主线) if is_fission: new_parent_task_id = task.id new_task_type = "SPAWN" # 裂变产生新分支 elif is_child_task: new_parent_task_id = task.parent_task_id new_task_type = task.task_type or "SPAWN" # 继承父任务的分支血统 else: new_parent_task_id = task.id new_task_type = "TRANSFER" # 主干常规流转 new_task = Task( product_id=task.product_id, parent_task_id=new_parent_task_id, task_name=task_name, assignee_id=assignee_id, status=TASK_STATUS_PENDING, task_type=new_task_type, # ← 使用动态推导的类型 notify_parent_on_complete=False, is_rework=False, remark=request.note or None, ) db.add(new_task) created_tasks.append(new_task) # 批量 flush 以生成 ID await db.flush() product_sn = product.serial_number if product else "" for nt in created_tasks: await _create_task_log( db, nt.id, action_type="create", operator_id=operator_id, 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( user_id=nt.assignee_id, title=f"🟢 新任务派发", content=f"产品 [{product_sn}] 的「{nt.task_name}」任务已分配给你", type=NOTIFY_TRANSFER, task_id=nt.id, )) # --- 更新 Product 的 current_location_id / overall_status --- # 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」, # 不改变产品的物理状态。已出库的设备完结后依然是已出库, # 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。 # 下方 has_warehouse / real_branches 两个分支对直接完结均为假, # overall_status 自然原样保留。 if product: if has_warehouse and not real_branches: # 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手), # 但宏观状态要分情况 —— # · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。 # 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」, # 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突 # (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。 # 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。 # · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。 product.current_location_id = VIRTUAL_WAREHOUSE if not _is_physical_terminal(product.overall_status): product.overall_status = "待仓库收货" elif real_branches: product.current_location_id = real_branches[0][1] # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— # 设备已是 已入库/在库/已出库 时禁止用任务名覆写, # 否则「已出库」会被「发货测试」这类工序名抹掉。 if ( (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")) and not _is_physical_terminal(product.overall_status) ): product.overall_status = real_branches[0][0] # 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码 # product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND) # 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。 sync_product_status(product) # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务 # 「直接完结」(finish_directly) 与旧版空分支都落到这里: # current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。 # 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。 if not real_branches and not has_warehouse: await _recalc_product_location(db, task.product_id, task_id) await db.commit() # --- 构建响应 --- refreshed_task = await _get_task_with_children_recursive(db, task_id) created_task_responses = [] for nt in created_tasks: await db.refresh(nt) created_task_responses.append(_to_response(nt)) # 🔧 直接完结没有下家,不能套用"已创建 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=message, ) # ============================================================ # 保留兼容:旧版 complete_task(单步完成/转交) # ============================================================ async def complete_task( db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest, operator_role: str | None = None, ) -> TaskCompleteResponse: """ 核心业务:完成任务 + 可选创建下一步任务。 逻辑: 1. 检查当前任务是否已完成(幂等) 2. 检查所有 notify_parent_on_complete=True 的子任务是否都已完成 → 如果存在未完成的关键子任务,返回 400 错误 3. 将当前任务状态改为 completed,记录日志 4. 如果提供了 next_task_name 和 next_assignee_id,创建下一步任务 5. 返回完成结果 """ task = await _get_task_or_404(db, task_id) # 权限校验:本人 或 管理员/主管 可操作 _check_permission(task.assignee_id, request.operator_id, operator_role) # --- 1. 幂等检查 --- if task.status == TASK_STATUS_COMPLETED: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"任务「{task.task_name}」已经完成,无需重复操作", ) # --- 2. 卡点逻辑:检查关键子任务 --- all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id) if not all_done: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}", ) now = get_beijing_time() # --- 3. 标记当前任务为已完成 --- task.status = TASK_STATUS_COMPLETED task.completed_at = now await _create_task_log( db, task_id, action_type="complete", operator_id=request.operator_id, 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 if request.next_task_name and request.next_assignee_id: # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期 # (出厂质检「发货测试」不触发) await _mark_after_sales_if_reactivated( db, task.product_id, request.next_task_name, ) # 🚀 智能父节点继承算法 # 主线任务转交 → 保持平级继承(主分支永远在一维主干上) # 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝) # 注:TASK_TYPE 实际值为 TRANSFER/RECOVERY/SPAWN,不存在 "MAIN" is_main_line = ( not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY") ) new_parent_id = task.parent_task_id if is_main_line else task.id next_task = Task( product_id=task.product_id, parent_task_id=new_parent_id, # 👈 智能计算 task_name=request.next_task_name, assignee_id=request.next_assignee_id, status=TASK_STATUS_PENDING, task_type=task.task_type, # 👈 基因严格继承(绝不篡位成 MAIN) notify_parent_on_complete=False, ) db.add(next_task) await db.flush() await _create_task_log( db, next_task.id, action_type="create", operator_id=request.operator_id, 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) await db.commit() # --- 5. 构建响应 --- refreshed_task = await _get_task_with_children_recursive(db, task_id) next_task_response = None if next_task: await db.refresh(next_task) next_task_response = _to_response(next_task) return TaskCompleteResponse( completed_task=_to_response(refreshed_task), next_task=next_task_response, message=f"任务「{task.task_name}」已完成" + (f",已创建下一步任务「{request.next_task_name}」" if next_task else ""), ) # ============================================================ # 子任务 # ============================================================ async def create_subtask( db: AsyncSession, parent_task_id: uuid.UUID, data: SubtaskCreate ) -> TaskResponse: """ 在现有任务下创建子任务 — 支持无限层级嵌套。 新子任务继承父任务的 product_id。 """ parent = await _get_task_or_404(db, parent_task_id) # 禁止在已完成/已驳回的任务下创建子任务 if parent.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"无法在状态为 {parent.status} 的任务「{parent.task_name}」下创建子任务", ) subtask = Task( product_id=parent.product_id, parent_task_id=parent_task_id, task_name=data.task_name, assignee_id=data.assignee_id, status=TASK_STATUS_PENDING, notify_parent_on_complete=data.notify_parent_on_complete, ) db.add(subtask) await db.commit() await db.refresh(subtask) await _create_task_log( db, subtask.id, action_type="create", operator_id=data.assignee_id, remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}」", task_assignee_id=subtask.assignee_id, ) await db.commit() return _to_response(subtask) # ============================================================ # 任务进度记录 — 随时备注/传图 # ============================================================ async def add_task_record( db: AsyncSession, task_id: uuid.UUID, data: TaskRecordCreate, current_user: dict | None = None, ) -> TaskResponse: """追加进度记录(备注+图片),不改变任务状态""" import json task = await _get_task_with_children_recursive(db, task_id) record = TaskRecord( task_id=task_id, remark=data.remark or None, images=json.dumps(data.images) if data.images else None, ) db.add(record) await db.flush() # 🚀 留言通知:给任务当前负责人发送提醒(不给自己发) if ( current_user and task.assignee_id and task.assignee_id != current_user.get("username", "") and data.remark ): # 截取留言内容前 30 字作为摘要 remark_text = data.remark.strip() short_content = remark_text[:30] + ("..." if len(remark_text) > 30 else "") # 查询产品条码 product_result = await db.execute( select(Product).where(Product.id == task.product_id) ) product = product_result.scalar_one_or_none() product_sn = product.serial_number if product else "未知" db.add(Notification( user_id=task.assignee_id, title="💬 收到新留言", content=f"产品 [{product_sn}] 的「{task.task_name}」有新留言:{short_content}", type="COMMENT", task_id=task.id, )) await db.commit() await db.refresh(record) # 手动追加新 record,避免二次加载整棵树(task 已在 L1051 由 CTE 完整加载) task.records.append(record) return _to_response(task)