diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index cb29b9e..f017ceb 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -101,9 +101,34 @@ func ListTickets(c *gin.Context) { var tickets []model.Ticket query.Offset((page - 1) * limit).Limit(limit).Find(&tickets) - + + // Enrich tickets with has_audit_step info + type TicketWithMeta struct { + model.Ticket + HasAuditStep bool `json:"has_audit_step"` + HasExecuteStep bool `json:"has_execute_step"` + IsExecutor bool `json:"is_executor"` + IsApprover bool `json:"is_approver"` + } + var result []TicketWithMeta + for _, t := range tickets { + var auditCount, execCount int64 + model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'audit'", t.ID).Count(&auditCount) + model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute'", t.ID).Count(&execCount) + var isExec, isAppr int64 + model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute' AND approver_id = ?", t.ID, userID).Count(&isExec) + model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type != 'execute' AND step_type != 'submit' AND approver_id = ?", t.ID, userID).Count(&isAppr) + result = append(result, TicketWithMeta{ + Ticket: t, + HasAuditStep: auditCount > 0, + HasExecuteStep: execCount > 0, + IsExecutor: isExec > 0, + IsApprover: isAppr > 0, + }) + } + c.JSON(http.StatusOK, gin.H{ - "items": tickets, + "items": result, "total": total, "page": page, "page_size": limit, @@ -119,9 +144,31 @@ func GetTicket(c *gin.Context) { return } - // Get approvals + // Get approvals — enrich with user display names var approvals []model.TicketApproval - model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals) + model.DB.Where("ticket_id = ?", id).Order("step_number, id").Find(&approvals) + type ApprovalWithNames struct { + model.TicketApproval + ApproverName string `json:"approver_name"` + ActualApproverName string `json:"actual_approver_name"` + } + var approvalsResult []ApprovalWithNames + for _, a := range approvals { + item := ApprovalWithNames{TicketApproval: a} + if a.ApproverID != nil { + var u model.User + if model.DB.First(&u, *a.ApproverID).Error == nil { + item.ApproverName = u.DisplayName + } + } + if a.ActualApproverID != nil { + var u model.User + if model.DB.First(&u, *a.ActualApproverID).Error == nil { + item.ActualApproverName = u.DisplayName + } + } + approvalsResult = append(approvalsResult, item) + } // Get SQL items var sqlItems []model.TicketSQLItem @@ -132,7 +179,7 @@ func GetTicket(c *gin.Context) { // Marshal ticket to JSON then unmarshal to map for flattening ticketBytes, _ := json.Marshal(ticket) json.Unmarshal(ticketBytes, &result) - result["approvals"] = approvals + result["approvals"] = approvalsResult result["sql_items"] = sqlItems // Add applicant_name and instance_name for frontend @@ -213,6 +260,7 @@ func CreateTicket(c *gin.Context) { } // Create approval records based on workflow or default to admin + hasAuditStep := false if input.WorkflowID != nil && *input.WorkflowID > 0 { // Load workflow steps var wfSteps []model.WorkflowStep @@ -221,6 +269,9 @@ func CreateTicket(c *gin.Context) { if ws.StepType == "submit" { continue // submit step needs no approver } + if ws.StepType != "execute" { + hasAuditStep = true + } // Parse approver_ids from JSON var approverIDs []int if ws.ApproverIDs != nil && *ws.ApproverIDs != "" { @@ -283,7 +334,14 @@ func CreateTicket(c *gin.Context) { model.DB.Create(&approval) } } - + + // If workflow has no audit step (only submit + execute), skip to reviewing + if !hasAuditStep { + reviewing := "reviewing" + model.DB.Model(&ticket).Update("status", reviewing) + ticket.Status = &reviewing + } + c.JSON(http.StatusCreated, ticket) } @@ -369,11 +427,57 @@ func ExecuteTicket(c *gin.Context) { return } - // Check ticket status - if ticket.Status != nil && *ticket.Status != "approved" { + // Check ticket status — allow approved OR reviewing (will auto-approve execute step) + if ticket.Status != nil && *ticket.Status != "approved" && *ticket.Status != "reviewing" { c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"}) return } + + // Auto-approve any pending execute-type approval for the current user + userID, _, _ := middleware.GetCurrentUser(c) + + // Verify current user is an executor for this ticket + var executorCheck int64 + model.DB.Model(&model.TicketApproval{}). + Where("ticket_id = ? AND step_type = 'execute' AND (approver_id = ? OR approver_id IS NULL)", id, userID). + Count(&executorCheck) + if executorCheck == 0 { + c.JSON(http.StatusForbidden, gin.H{"detail": "你不是此工单的执行人"}) + return + } + + var pendingExec model.TicketApproval + if err := model.DB.Where("ticket_id = ? AND step_type = 'execute' AND action IS NULL", id).First(&pendingExec).Error; err == nil { + // Auto-approve the execute step + now := time.Now() + action := "approve" + comment := "自动通过(执行触发)" + pendingExec.Action = &action + pendingExec.Comment = &comment + pendingExec.ActualApproverID = &userID + pendingExec.CompletedAt = &now + model.DB.Save(&pendingExec) + } + + // Check if all audit steps are approved + var pendingAudit int64 + model.DB.Model(&model.TicketApproval{}). + Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id). + Count(&pendingAudit) + if pendingAudit > 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"}) + return + } + + // Check for rejected audit steps + var rejectedAudit int64 + model.DB.Model(&model.TicketApproval{}). + Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id). + Count(&rejectedAudit) + if rejectedAudit > 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"}) + return + } // Get instance info var instance model.Instance diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index 9a1a96f..7edcfc1 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -45,6 +45,25 @@ interface TicketItem { create_time: string; review_time: string | null; execute_time: string | null; + has_audit_step: boolean; + has_execute_step: boolean; + is_executor: boolean; + is_approver: boolean; + approvals?: ApprovalRecord[]; +} + +interface ApprovalRecord { + id: number; + step_number: number; + step_name: string | null; + step_type: string | null; + approver_id: number | null; + approver_name: string; + actual_approver_id: number | null; + actual_approver_name: string; + action: string | null; + comment: string | null; + completed_at: string | null; } interface TicketListResponse { @@ -426,11 +445,11 @@ export default function TicketsPage() { {ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'} - {canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( + {canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( )} - {canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( + {canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( )} - {canExecute && ticket.status === 'approved' && ( + {canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && ( )} - {canExecute && ticket.status === 'approved' && ( + {canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (