diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index ae187b5..fe75982 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -51,6 +51,7 @@ class ProductResponse(BaseModel): parent_product_id: uuid.UUID | None current_location_id: str | None = None current_location_name: str | None = None + macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开) overall_status: str | None = None status: str created_at: datetime diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 4cbc6fb..3cdb00f 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -384,6 +384,30 @@ async def get_all_products( location_ids = [p.current_location_id for p in products if p.current_location_id] name_map = _lookup_display_names(location_ids) + # 🔧 批量预计算 macro_status:一次性查出所有产品关联的任务状态 + product_ids = [p.id for p in products] + macro_map: dict[uuid.UUID, str] = {} + if product_ids: + from sqlalchemy import case, func as sa_func + task_stmt = ( + select( + Task.product_id, + sa_func.max(case( + (Task.status == "WIP", 3), + (Task.status == "PENDING", 2), + (Task.status == "COMPLETED", 1), + (Task.status == "ARCHIVED", 1), + else_=0, + )).label("prio"), + ) + .where(Task.product_id.in_(product_ids)) + .group_by(Task.product_id) + ) + task_result = await db.execute(task_stmt) + prio_to_status = {3: "WIP", 2: "PENDING", 1: "COMPLETED", 0: None} + for row in task_result: + macro_map[row[0]] = prio_to_status.get(row[1], None) + return [ ProductResponse( id=p.id, @@ -403,6 +427,7 @@ async def get_all_products( else name_map.get(p.current_location_id) if p.current_location_id else None ), + macro_status=macro_map.get(p.id) or p.status, # 优先任务树状态,兜底产品状态 overall_status=p.overall_status, status=p.status, created_at=p.created_at, diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 3c3416d..e75cfdb 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -74,6 +74,7 @@ async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID, comp sa_select(Task).where( Task.product_id == product_id, Task.status == TASK_STATUS_WIP, + Task.id != completed_task_id, # 🔧 排除刚刚完工的任务(避免脏读) ).order_by(Task.created_at.desc()) ) wip_tasks = task_result.scalars().all() @@ -94,6 +95,7 @@ async def _recalc_product_location(db: AsyncSession, product_id: uuid.UUID, comp if product.current_location_id != new_location: product.current_location_id = new_location + await db.flush() # 🔧 确保位置更新落盘到当前事务 def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None: diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index a6062ca..e05d34d 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -271,12 +271,24 @@ interface CommonProps { onViewRecords: (t: TaskResponse) => void; } +const MAX_DEPTH = 10; // 🔧 防爆栈:递归深度上限 + const TaskNode = memo(function TaskNode({ - task, isBranch, common, + task, isBranch, common, depth = 0, }: { task: TaskResponse; isBranch: boolean; - common: CommonProps; + common: CommonProps; depth?: number; }) { + // 🔧 防循环引用/脏数据导致死循环 + if (depth > MAX_DEPTH) { + return ( +
+

⚠ 树深度超限

+

请检查数据完整性

+
+ ); + } + const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP; @@ -320,7 +332,7 @@ const TaskNode = memo(function TaskNode({ {/* 横线连接 */}
- +
))} diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index ca39a5f..08373f1 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -81,15 +81,18 @@ export default function AdminTasksPage() { loadProducts(keyword); } - /** 🔧 本地计算产品综合状态(与表格列渲染逻辑100%统一) */ + /** 🔧 产品综合状态:优先后端预计算的 macro_status,兜底本地 taskTree */ function calcProductStatus(p: ProductResponse): string { + // 后端已批量预计算 macro_status(WIP/PENDING/COMPLETED) + if (p.macro_status) return p.macro_status; + // 兜底:展开过的流转树 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; // 未展开流转树时兜底产品状态 + return p.status; } // ---- 按订单分组 + 本地状态过滤 ---- diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index f1a100b..de13fb4 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -14,6 +14,7 @@ export interface ProductResponse { parent_product_id: string | null; current_location_id: string | null; current_location_name: string | null; + macro_status: string | null; overall_status: string | null; status: string; created_at: string;