diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 3ec0c92..3c3416d 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -34,19 +34,42 @@ VIRTUAL_WAREHOUSE = "virtual_warehouse" ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"} -async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID) -> None: +async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID, completed_task_id: uuid.UUID | None = None) -> None: """ - 任务完工/结束时触发:沿任务树向上回溯, - 将产品 current_location 更新为最近一个 WIP 任务的负责人。 - 若无进行中任务,位置置空。 + 任务完工/结束时触发:位置回溯 父任务优先 策略。 + ① 如果完工任务有父任务且父任务未完成 → 强制回溯到父任务负责人 + ② 否则查找所有 WIP 任务 → 最新 WIP 负责人 + ③ 无 WIP → 兜底最后完工者 + ④ 全结束 → 置空 """ from sqlalchemy import select as sa_select + + # ① 父任务优先:如果有父任务且未完成 → 位置给父任务负责人 + if completed_task_id: + task_result = await db.execute( + sa_select(Task).where(Task.id == completed_task_id) + ) + current_task = task_result.scalar_one_or_none() + if current_task and current_task.parent_task_id: + parent_result = await db.execute( + sa_select(Task).where(Task.id == current_task.parent_task_id) + ) + parent = parent_result.scalar_one_or_none() + if parent and parent.status not in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED): + product_result = await db.execute( + sa_select(Product).where(Product.id == product_id) + ) + product = product_result.scalar_one_or_none() + if product and product.current_location_id != parent.assignee_id: + product.current_location_id = parent.assignee_id + return + + # ② 无父任务/父任务已完工 → 查找其他 WIP 任务 product_result = await db.execute(sa_select(Product).where(Product.id == product_id)) product = product_result.scalar_one_or_none() if not product: return - # 查找所有 WIP 状态的任务 task_result = await db.execute( sa_select(Task).where( Task.product_id == product_id, @@ -56,7 +79,6 @@ async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID) -> N wip_tasks = task_result.scalars().all() if wip_tasks: - # 有进行中的任务 → 位置更新为最新WIP任务的负责人 latest_wip = wip_tasks[0] new_location = latest_wip.assignee_id or product.current_location_id else: @@ -364,8 +386,8 @@ async def end_task( await _create_task_log(db, task_id, action_type="end", operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)") - # 🔧 位置回溯:分支结束后重新计算产品当前位置 - await _recalc_product_location(db, task.product_id) + # 🔧 位置回溯:分支结束后优先回溯到父任务负责人 + await _recalc_product_location(db, task.product_id, task.id) await db.commit() await db.refresh(task) @@ -806,9 +828,9 @@ async def transfer_task( product.current_location_id = real_branches[0][1] product.overall_status = real_branches[0][0] - # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到上级WIP任务 + # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务 if not real_branches and not has_warehouse: - await _recalc_product_location(db, task.product_id) + await _recalc_product_location(db, task.product_id, task_id) await db.commit() @@ -908,8 +930,8 @@ async def complete_task( remark=f"由任务「{task.task_name}」完成后转交创建", ) - # 🔧 位置回溯:老接口也触发 - await _recalc_product_location(db, task.product_id) + # 🔧 位置回溯:老接口也触发(父任务优先) + await _recalc_product_location(db, task.product_id, task_id) await db.commit() diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index c2305be..ca39a5f 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -55,14 +55,13 @@ export default function AdminTasksPage() { const [modalTarget, setModalTarget] = useState(null); const [submitting, setSubmitting] = useState(false); - // ---- 加载产品列表 ---- - async function loadProducts(kw: string, st: string) { + // ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ---- + async function loadProducts(kw: string) { setLoading(true); setError(null); try { const params: Record = { limit: 1000 }; if (kw.trim()) params.keyword = kw.trim(); - if (st) params.status = st; const { data } = await api.get("/products/", { params }); setProducts(data); } catch { @@ -72,28 +71,36 @@ export default function AdminTasksPage() { } } - // 首次加载 - useEffect(() => { - loadProducts(keyword, statusFilter); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // 🚀 状态筛选 Tab 变化时自动重新查询 - useEffect(() => { - loadProducts(keyword, statusFilter); - }, [statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps function handleSearch(e?: React.FormEvent) { e?.preventDefault(); setExpandedOrders(new Set()); setExpandedProducts(new Set()); setTaskTrees({}); - loadProducts(keyword, statusFilter); + loadProducts(keyword); } - // ---- 按订单分组 ---- + /** 🔧 本地计算产品综合状态(与表格列渲染逻辑100%统一) */ + function calcProductStatus(p: ProductResponse): string { + const tree = taskTrees[p.serial_number]; + if (tree?.task_tree?.length) { + if (tree.task_tree.some(t => t.status === "WIP")) return "WIP"; + if (tree.task_tree.every(t => t.status === "COMPLETED" || t.status === "ARCHIVED")) return "COMPLETED"; + return tree.task_tree[0].status; + } + return p.status; // 未展开流转树时兜底产品状态 + } + + // ---- 按订单分组 + 本地状态过滤 ---- const orderGroups = useMemo(() => { const map = new Map(); for (const p of products) { + // 🔧 本地过滤:Tab切换时不再请求后端 + if (statusFilter) { + const s = calcProductStatus(p).toUpperCase(); + if (s !== statusFilter) continue; + } const key = p.order_no || "未绑定订单"; if (!map.has(key)) map.set(key, []); map.get(key)!.push(p); @@ -107,6 +114,7 @@ export default function AdminTasksPage() { (p) => p.current_location_id === "virtual_warehouse" ), })); + }, [products, statusFilter, taskTrees]); }, [products]); // ---- 手风琴切换 ---- @@ -170,7 +178,7 @@ export default function AdminTasksPage() { const sn = modalTarget.task.product_sn || ""; setModalTarget(null); if (sn) await refreshProductTree(sn); - await loadProducts(keyword, statusFilter); + await loadProducts(keyword); } catch (err: any) { toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error"); } finally { @@ -187,7 +195,7 @@ export default function AdminTasksPage() { const sn = modalTarget.task.product_sn || ""; setModalTarget(null); if (sn) await refreshProductTree(sn); - await loadProducts(keyword, statusFilter); + await loadProducts(keyword); } catch (err: any) { toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error"); } finally { @@ -210,7 +218,7 @@ export default function AdminTasksPage() { const sn = modalTarget.task.product_sn || ""; setModalTarget(null); if (sn) await refreshProductTree(sn); - await loadProducts(keyword, statusFilter); + await loadProducts(keyword); } catch (err: any) { toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error"); } finally {