1631 lines
49 KiB
Go
1631 lines
49 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/constants"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/dto/request"
|
|
"agent-desk/internal/pkg/dto/response"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/errorsx"
|
|
"agent-desk/internal/pkg/utils"
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
)
|
|
|
|
var SetupReadinessService = newSetupReadinessService()
|
|
|
|
func newSetupReadinessService() *setupReadinessService {
|
|
return &setupReadinessService{}
|
|
}
|
|
|
|
type setupReadinessService struct{}
|
|
|
|
const (
|
|
QuickFixActionQuickSetupAll = "quick_setup_all"
|
|
QuickFixActionEnsureTeamSchedule = "ensure_team_and_schedule"
|
|
QuickFixActionEnableMyProfile = "enable_my_operator_profile"
|
|
QuickFixActionFixAIAgentHandoff = "fix_ai_agent_handoff"
|
|
QuickFixActionEnsureDefaultChannel = "ensure_default_channel"
|
|
)
|
|
|
|
func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*response.SetupReadinessResponse, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
|
|
var teams []models.AgentTeam
|
|
if err := db.Order("id ASC").Find(&teams).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeTeamMap := make(map[int64]models.AgentTeam)
|
|
activeTeamIDs := make([]int64, 0, len(teams))
|
|
for _, team := range teams {
|
|
if team.Status == enums.StatusOk {
|
|
activeTeamMap[team.ID] = team
|
|
activeTeamIDs = append(activeTeamIDs, team.ID)
|
|
}
|
|
}
|
|
|
|
var schedules []models.AgentTeamSchedule
|
|
if err := db.Order("id ASC").Find(&schedules).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeScheduleNowTeamSet := make(map[int64]bool)
|
|
anyScheduleTeamSet := make(map[int64]bool)
|
|
for _, sch := range schedules {
|
|
if sch.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
anyScheduleTeamSet[sch.TeamID] = true
|
|
if !sch.StartAt.After(now) && sch.EndAt.After(now) {
|
|
activeScheduleNowTeamSet[sch.TeamID] = true
|
|
}
|
|
}
|
|
|
|
var profiles []models.AgentProfile
|
|
if err := db.Order("id ASC").Find(&profiles).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var users []models.User
|
|
if err := db.Find(&users).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeUserMap := make(map[int64]models.User, len(users))
|
|
for _, u := range users {
|
|
if u.Status == enums.StatusOk && u.DeletedAt == nil {
|
|
activeUserMap[u.ID] = u
|
|
}
|
|
}
|
|
|
|
activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(profiles))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var aiAgents []models.AIAgent
|
|
if err := db.Order("id ASC").Find(&aiAgents).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var channels []models.Channel
|
|
if err := db.Order("id ASC").Find(&channels).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 1: Operator Teams
|
|
step1 := s.evaluateTeamsStep(teams, activeTeamIDs)
|
|
|
|
// Step 2: Shift Schedules (Active Now)
|
|
step2 := s.evaluateSchedulesStep(teams, activeTeamMap, activeScheduleNowTeamSet, anyScheduleTeamSet)
|
|
|
|
// Step 3: Operator Profiles
|
|
step3, readyOperatorTeams := s.evaluateOperatorsStep(profiles, activeTeamMap, activeScheduleNowTeamSet, activeUserMap, activeCounts)
|
|
|
|
// Step 4: AI Agents & Handoff Configuration
|
|
step4, readyAIAgentMap := s.evaluateAIAgentsStep(aiAgents, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams)
|
|
|
|
// Step 5: Inbound Channels
|
|
step5, channelBriefs := s.evaluateChannelsStep(channels, aiAgents)
|
|
|
|
// Step 6: End-to-End Routing Path
|
|
step6, canReceiveRequestsNow := s.evaluateEndToEndStep(channels, aiAgents, readyAIAgentMap, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams)
|
|
|
|
steps := []response.SetupStepItem{step1, step2, step3, step4, step5, step6}
|
|
for i := range steps {
|
|
steps[i] = s.filterStepQuickFixes(steps[i], operator)
|
|
}
|
|
|
|
readySteps := 0
|
|
hasError := false
|
|
hasWarning := false
|
|
for _, st := range steps {
|
|
switch st.Status {
|
|
case "ok":
|
|
readySteps++
|
|
case "error":
|
|
hasError = true
|
|
case "warning":
|
|
hasWarning = true
|
|
}
|
|
}
|
|
|
|
overallStatus := "ok"
|
|
if hasError || !canReceiveRequestsNow {
|
|
if hasError {
|
|
overallStatus = "error"
|
|
} else {
|
|
overallStatus = "warning"
|
|
}
|
|
} else if hasWarning {
|
|
overallStatus = "warning"
|
|
}
|
|
|
|
currentOp := s.evaluateCurrentOperator(operator, profiles, activeTeamMap, activeScheduleNowTeamSet, activeCounts)
|
|
|
|
return &response.SetupReadinessResponse{
|
|
OverallStatus: overallStatus,
|
|
ReadySteps: readySteps,
|
|
TotalSteps: len(steps),
|
|
CanReceiveRequestsNow: canReceiveRequestsNow,
|
|
Steps: steps,
|
|
CurrentOperator: currentOp,
|
|
Channels: channelBriefs,
|
|
}, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateTeamsStep(teams []models.AgentTeam, activeTeamIDs []int64) response.SetupStepItem {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
|
|
if len(activeTeamIDs) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_team"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_team",
|
|
Severity: "error",
|
|
EntityType: "team",
|
|
QuickFixAction: QuickFixActionEnsureTeamSchedule,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "teams",
|
|
StepNumber: 1,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(teams),
|
|
ReadyCount: len(activeTeamIDs),
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agents",
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateSchedulesStep(
|
|
teams []models.AgentTeam,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
anyScheduleTeamSet map[int64]bool,
|
|
) response.SetupStepItem {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyTeamCount := 0
|
|
|
|
for _, team := range teams {
|
|
if team.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
if activeScheduleNowTeamSet[team.ID] {
|
|
readyTeamCount++
|
|
continue
|
|
}
|
|
issueCode := "team_no_schedule_configured"
|
|
if anyScheduleTeamSet[team.ID] {
|
|
issueCode = "team_off_hours_now"
|
|
}
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: issueCode,
|
|
Severity: "warning",
|
|
EntityType: "team",
|
|
EntityID: team.ID,
|
|
EntityName: team.Name,
|
|
QuickFixAction: QuickFixActionEnsureTeamSchedule,
|
|
})
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if len(activeTeamMap) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_team"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule)
|
|
} else if readyTeamCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_schedule_now"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "partial_schedule_now"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "schedules",
|
|
StepNumber: 2,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(activeTeamMap),
|
|
ReadyCount: readyTeamCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agent-team-schedules",
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateOperatorsStep(
|
|
profiles []models.AgentProfile,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
activeUserMap map[int64]models.User,
|
|
activeCounts map[int64]int,
|
|
) (response.SetupStepItem, map[int64]bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
readyOperatorTeams := make(map[int64]bool)
|
|
|
|
for _, p := range profiles {
|
|
displayName := strings.TrimSpace(p.DisplayName)
|
|
if displayName == "" {
|
|
if u, ok := activeUserMap[p.UserID]; ok {
|
|
displayName = firstNonEmpty(u.Nickname, u.Username)
|
|
}
|
|
}
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("#%d", p.UserID)
|
|
}
|
|
|
|
if p.Status != enums.StatusOk {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_profile_disabled",
|
|
Severity: "warning",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
continue
|
|
}
|
|
if _, userOk := activeUserMap[p.UserID]; !userOk {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_user_disabled",
|
|
Severity: "warning",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
})
|
|
continue
|
|
}
|
|
if _, teamOk := activeTeamMap[p.TeamID]; !teamOk {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_no_active_team",
|
|
Severity: "error",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
continue
|
|
}
|
|
if !p.AutoAssignEnabled {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_auto_assign_off",
|
|
Severity: "warning",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
continue
|
|
}
|
|
if p.ServiceStatus != enums.ServiceStatusIdle {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_busy",
|
|
Severity: "warning",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
continue
|
|
}
|
|
activeCount := activeCounts[p.UserID]
|
|
if p.MaxConcurrentCount <= 0 || activeCount >= p.MaxConcurrentCount {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "operator_at_capacity",
|
|
Severity: "warning",
|
|
EntityType: "operator",
|
|
EntityID: p.ID,
|
|
EntityName: displayName,
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
continue
|
|
}
|
|
|
|
readyCount++
|
|
if activeScheduleNowTeamSet[p.TeamID] {
|
|
readyOperatorTeams[p.TeamID] = true
|
|
}
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if len(profiles) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_operator_profile"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_operator_profile",
|
|
Severity: "error",
|
|
EntityType: "operator",
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnableMyProfile)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_idle_operator"
|
|
quickFixes = append(quickFixes, QuickFixActionEnableMyProfile)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "some_operators_unavailable"
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "operators",
|
|
StepNumber: 3,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(profiles),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agents",
|
|
}, readyOperatorTeams
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateAIAgentsStep(
|
|
aiAgents []models.AIAgent,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
readyOperatorTeams map[int64]bool,
|
|
) (response.SetupStepItem, map[int64]bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
readyAIAgentMap := make(map[int64]bool)
|
|
|
|
activeAgentCount := 0
|
|
for _, agent := range aiAgents {
|
|
if agent.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeAgentCount++
|
|
if agent.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_ai_only_mode",
|
|
Severity: "warning",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
continue
|
|
}
|
|
teamIDs := utils.SplitInt64s(agent.TeamIDs)
|
|
validTeamIDs := make([]int64, 0, len(teamIDs))
|
|
for _, tid := range teamIDs {
|
|
if _, ok := activeTeamMap[tid]; ok {
|
|
validTeamIDs = append(validTeamIDs, tid)
|
|
}
|
|
}
|
|
if len(validTeamIDs) == 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_no_teams",
|
|
Severity: "error",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
continue
|
|
}
|
|
if agent.ServiceMode == enums.IMConversationServiceModeAIFirst && !agent.AutoHandoffOnFailure {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_auto_handoff_disabled",
|
|
Severity: "warning",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
}
|
|
|
|
readyCount++
|
|
for _, tid := range validTeamIDs {
|
|
if activeScheduleNowTeamSet[tid] && readyOperatorTeams[tid] {
|
|
readyAIAgentMap[agent.ID] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if activeAgentCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_ai_agent"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_ai_agent",
|
|
Severity: "error",
|
|
EntityType: "ai_agent",
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_handoff_ready_agent"
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "agent_has_warnings"
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "ai_agents",
|
|
StepNumber: 4,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(aiAgents),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/ai-agents",
|
|
}, readyAIAgentMap
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateChannelsStep(
|
|
channels []models.Channel,
|
|
aiAgents []models.AIAgent,
|
|
) (response.SetupStepItem, []response.SetupChannelBrief) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
|
|
agentMap := make(map[int64]models.AIAgent, len(aiAgents))
|
|
for _, a := range aiAgents {
|
|
if a.Status == enums.StatusOk {
|
|
agentMap[a.ID] = a
|
|
}
|
|
}
|
|
|
|
briefs := make([]response.SetupChannelBrief, 0, len(channels))
|
|
activeChannelCount := 0
|
|
for _, ch := range channels {
|
|
briefs = append(briefs, response.SetupChannelBrief{
|
|
ID: ch.ID,
|
|
Name: ch.Name,
|
|
ChannelType: ch.ChannelType,
|
|
ChannelID: ch.ChannelID,
|
|
AIAgentID: ch.AIAgentID,
|
|
RolloutPercent: ch.AIAgentRolloutPercent,
|
|
Status: int(ch.Status),
|
|
})
|
|
if ch.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeChannelCount++
|
|
if ch.AIAgentID <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_no_agent",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
if _, ok := agentMap[ch.AIAgentID]; !ok {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_agent_disabled",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
if ch.AIAgentRolloutPercent <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_zero_rollout",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
readyCount++
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if activeChannelCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_channel"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_channel",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_ready_channel"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "some_channels_misconfigured"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "channels",
|
|
StepNumber: 5,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(channels),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/channels",
|
|
}, briefs
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateEndToEndStep(
|
|
channels []models.Channel,
|
|
aiAgents []models.AIAgent,
|
|
readyAIAgentMap map[int64]bool,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
readyOperatorTeams map[int64]bool,
|
|
) (response.SetupStepItem, bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyChannelCount := 0
|
|
activeChannelCount := 0
|
|
|
|
for _, ch := range channels {
|
|
if ch.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeChannelCount++
|
|
if ch.AIAgentRolloutPercent > 0 && readyAIAgentMap[ch.AIAgentID] {
|
|
readyChannelCount++
|
|
}
|
|
}
|
|
|
|
canReceiveNow := readyChannelCount > 0
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
|
|
if !canReceiveNow {
|
|
status = "error"
|
|
summaryCode = "no_end_to_end_path"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_end_to_end_path",
|
|
Severity: "error",
|
|
EntityType: "system",
|
|
QuickFixAction: QuickFixActionQuickSetupAll,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionQuickSetupAll)
|
|
}
|
|
|
|
_ = aiAgents
|
|
_ = activeTeamMap
|
|
_ = activeScheduleNowTeamSet
|
|
_ = readyOperatorTeams
|
|
|
|
return response.SetupStepItem{
|
|
Key: "end_to_end",
|
|
StepNumber: 6,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: max(activeChannelCount, 1),
|
|
ReadyCount: readyChannelCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/setup",
|
|
}, canReceiveNow
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateCurrentOperator(
|
|
operator *dto.AuthPrincipal,
|
|
profiles []models.AgentProfile,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
activeCounts map[int64]int,
|
|
) response.CurrentOperatorDiagnostic {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return response.CurrentOperatorDiagnostic{
|
|
BlockerCodes: []string{"not_authenticated"},
|
|
}
|
|
}
|
|
|
|
diag := response.CurrentOperatorDiagnostic{
|
|
UserID: operator.UserID,
|
|
Username: operator.Username,
|
|
Nickname: operator.Nickname,
|
|
BlockerCodes: make([]string, 0),
|
|
}
|
|
|
|
var matched *models.AgentProfile
|
|
for i := range profiles {
|
|
if profiles[i].UserID == operator.UserID {
|
|
matched = &profiles[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if matched == nil {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "missing_profile")
|
|
return diag
|
|
}
|
|
|
|
diag.HasProfile = true
|
|
diag.ProfileStatusOk = matched.Status == enums.StatusOk
|
|
diag.TeamID = matched.TeamID
|
|
diag.ServiceStatus = int(matched.ServiceStatus)
|
|
diag.AutoAssignEnabled = matched.AutoAssignEnabled
|
|
diag.ActiveConversationCount = int64(activeCounts[operator.UserID])
|
|
diag.MaxConcurrentCount = matched.MaxConcurrentCount
|
|
|
|
if !diag.ProfileStatusOk {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "profile_disabled")
|
|
}
|
|
|
|
if team, ok := activeTeamMap[matched.TeamID]; ok {
|
|
diag.TeamName = team.Name
|
|
diag.TeamActive = true
|
|
diag.TeamHasScheduleNow = activeScheduleNowTeamSet[team.ID]
|
|
if !diag.TeamHasScheduleNow {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "team_off_hours")
|
|
}
|
|
} else {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "no_team")
|
|
}
|
|
|
|
if !matched.AutoAssignEnabled {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "auto_assign_disabled")
|
|
}
|
|
if matched.ServiceStatus != enums.ServiceStatusIdle {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "status_busy")
|
|
}
|
|
if matched.MaxConcurrentCount <= 0 || int(diag.ActiveConversationCount) >= matched.MaxConcurrentCount {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "at_capacity")
|
|
}
|
|
|
|
diag.CanReceiveNow = len(diag.BlockerCodes) == 0
|
|
return diag
|
|
}
|
|
|
|
func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
fixIfAllowed := func(action string) string {
|
|
if s.canRunQuickFixAction(operator, action) {
|
|
return action
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var channel models.Channel
|
|
if req.ChannelID > 0 {
|
|
if err := db.First(&channel, req.ChannelID).Error; err != nil {
|
|
return &response.SetupSimulateRouteResponse{
|
|
WillReachOperator: false,
|
|
FinalOutcomeCode: "blocked_no_channel",
|
|
Hops: []response.SimulatedRouteHop{
|
|
{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_no_channel",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
} else {
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil {
|
|
if err2 := db.Order("id ASC").First(&channel).Error; err2 != nil {
|
|
return &response.SetupSimulateRouteResponse{
|
|
WillReachOperator: false,
|
|
FinalOutcomeCode: "blocked_no_channel",
|
|
Hops: []response.SimulatedRouteHop{
|
|
{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_no_channel",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := &response.SetupSimulateRouteResponse{
|
|
ChannelID: channel.ID,
|
|
ChannelName: channel.Name,
|
|
ChannelType: channel.ChannelType,
|
|
Hops: make([]response.SimulatedRouteHop, 0, 5),
|
|
CandidateOperators: make([]response.SimulatedOperatorCandidate, 0),
|
|
}
|
|
|
|
// Hop 1: Channel
|
|
if channel.Status != enums.StatusOk {
|
|
resp.FinalOutcomeCode = "blocked_channel_disabled"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_channel_disabled",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if channel.AIAgentRolloutPercent <= 0 {
|
|
resp.FinalOutcomeCode = "blocked_zero_rollout"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_zero_rollout",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if channel.AIAgentID <= 0 {
|
|
resp.FinalOutcomeCode = "blocked_missing_ai_agent"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_missing_ai_agent",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
})
|
|
|
|
// Hop 2: AI Agent
|
|
var aiAgent models.AIAgent
|
|
if err := db.First(&aiAgent, channel.AIAgentID).Error; err != nil || aiAgent.Status != enums.StatusOk {
|
|
resp.FinalOutcomeCode = "blocked_missing_ai_agent"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "ai_agent",
|
|
Status: "error",
|
|
ReasonCode: "blocked_missing_ai_agent",
|
|
EntityID: channel.AIAgentID,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
|
resp.FinalOutcomeCode = "blocked_ai_only_mode"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "ai_agent",
|
|
Status: "error",
|
|
ReasonCode: "blocked_ai_only_mode",
|
|
EntityID: aiAgent.ID,
|
|
EntityName: aiAgent.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
agentHopStatus := "ok"
|
|
agentReason := "ok_human_only"
|
|
if aiAgent.ServiceMode == enums.IMConversationServiceModeAIFirst {
|
|
agentReason = "ok_ai_first_handoff"
|
|
if !aiAgent.AutoHandoffOnFailure {
|
|
agentHopStatus = "warning"
|
|
agentReason = "warning_auto_handoff_off"
|
|
}
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "ai_agent",
|
|
Status: agentHopStatus,
|
|
ReasonCode: agentReason,
|
|
EntityID: aiAgent.ID,
|
|
EntityName: aiAgent.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
|
|
// Hop 3: Target Teams
|
|
requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
|
var teams []models.AgentTeam
|
|
if len(requestedTeamIDs) > 0 {
|
|
db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams)
|
|
}
|
|
if len(teams) == 0 {
|
|
resp.FinalOutcomeCode = "blocked_agent_no_teams"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "teams",
|
|
Status: "error",
|
|
ReasonCode: "blocked_agent_no_teams",
|
|
EntityID: aiAgent.ID,
|
|
EntityName: aiAgent.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
|
|
teamNames := make([]string, 0, len(teams))
|
|
enabledTeamIDSet := make(map[int64]models.AgentTeam, len(teams))
|
|
enabledTeamIDs := make([]int64, 0, len(teams))
|
|
for _, t := range teams {
|
|
teamNames = append(teamNames, t.Name)
|
|
enabledTeamIDSet[t.ID] = t
|
|
enabledTeamIDs = append(enabledTeamIDs, t.ID)
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "teams",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityName: strings.Join(teamNames, ", "),
|
|
})
|
|
|
|
// Hop 4: Shift Schedules (Active Now)
|
|
activeScheduleTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(enabledTeamIDs, now)
|
|
activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs))
|
|
activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs))
|
|
for _, tid := range activeScheduleTeamIDs {
|
|
activeScheduleTeamSet[tid] = true
|
|
if t, ok := enabledTeamIDSet[tid]; ok {
|
|
activeScheduleNames = append(activeScheduleNames, t.Name)
|
|
}
|
|
}
|
|
|
|
if len(activeScheduleTeamIDs) == 0 {
|
|
resp.FinalOutcomeCode = "queued_off_hours_no_schedule"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "schedules",
|
|
Status: "error",
|
|
ReasonCode: "queued_off_hours_no_schedule",
|
|
EntityName: strings.Join(teamNames, ", "),
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureTeamSchedule),
|
|
})
|
|
resp.CandidateOperators = s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
|
return resp, nil
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "schedules",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityName: strings.Join(activeScheduleNames, ", "),
|
|
})
|
|
|
|
// Hop 5: Operator Candidate Selection
|
|
candidates := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
|
resp.CandidateOperators = candidates
|
|
|
|
eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(eligibleCandidates) == 0 {
|
|
resp.FinalOutcomeCode = "queued_no_eligible_operator"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "operators",
|
|
Status: "error",
|
|
ReasonCode: "queued_no_eligible_operator",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnableMyProfile),
|
|
})
|
|
return resp, nil
|
|
}
|
|
|
|
winner := eligibleCandidates[0]
|
|
winnerTeamName := ""
|
|
if t, ok := enabledTeamIDSet[winner.profile.TeamID]; ok {
|
|
winnerTeamName = t.Name
|
|
}
|
|
winnerDisplayName := firstNonEmpty(winner.profile.DisplayName, fmt.Sprintf("#%d", winner.profile.UserID))
|
|
matched := &response.SimulatedOperatorCandidate{
|
|
UserID: winner.profile.UserID,
|
|
DisplayName: winnerDisplayName,
|
|
TeamID: winner.profile.TeamID,
|
|
TeamName: winnerTeamName,
|
|
Eligible: true,
|
|
ActiveCount: int64(winner.activeCount),
|
|
MaxConcurrentCount: winner.profile.MaxConcurrentCount,
|
|
ServiceStatus: int(winner.profile.ServiceStatus),
|
|
AutoAssignEnabled: winner.profile.AutoAssignEnabled,
|
|
}
|
|
resp.MatchedOperator = matched
|
|
resp.WillReachOperator = true
|
|
resp.FinalOutcomeCode = "assigned_to_operator"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "operators",
|
|
Status: "ok",
|
|
ReasonCode: "assigned_to_operator",
|
|
EntityID: winner.profile.UserID,
|
|
EntityName: winnerDisplayName,
|
|
})
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) buildCandidateListForTeams(
|
|
enabledTeamIDSet map[int64]models.AgentTeam,
|
|
activeScheduleTeamSet map[int64]bool,
|
|
) []response.SimulatedOperatorCandidate {
|
|
db := sqls.DB()
|
|
var allProfiles []models.AgentProfile
|
|
db.Order("id ASC").Find(&allProfiles)
|
|
|
|
var allTeams []models.AgentTeam
|
|
db.Find(&allTeams)
|
|
teamNameMap := make(map[int64]string, len(allTeams))
|
|
for _, t := range allTeams {
|
|
teamNameMap[t.ID] = t.Name
|
|
}
|
|
|
|
var users []models.User
|
|
db.Find(&users)
|
|
userMap := make(map[int64]models.User, len(users))
|
|
for _, u := range users {
|
|
userMap[u.ID] = u
|
|
}
|
|
|
|
activeCounts, _ := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles))
|
|
result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles))
|
|
|
|
for _, p := range allProfiles {
|
|
u, hasUser := userMap[p.UserID]
|
|
displayName := strings.TrimSpace(p.DisplayName)
|
|
if displayName == "" && hasUser {
|
|
displayName = firstNonEmpty(u.Nickname, u.Username)
|
|
}
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("#%d", p.UserID)
|
|
}
|
|
|
|
skipReason := ""
|
|
switch {
|
|
case p.Status != enums.StatusOk:
|
|
skipReason = "profile_disabled"
|
|
case !hasUser || u.Status != enums.StatusOk || u.DeletedAt != nil:
|
|
skipReason = "user_disabled"
|
|
case enabledTeamIDSet[p.TeamID].ID == 0:
|
|
skipReason = "team_not_in_agent"
|
|
case !activeScheduleTeamSet[p.TeamID]:
|
|
skipReason = "team_off_hours"
|
|
case !p.AutoAssignEnabled:
|
|
skipReason = "auto_assign_off"
|
|
case p.ServiceStatus != enums.ServiceStatusIdle:
|
|
skipReason = "status_busy"
|
|
case p.MaxConcurrentCount <= 0 || activeCounts[p.UserID] >= p.MaxConcurrentCount:
|
|
skipReason = "at_capacity"
|
|
}
|
|
|
|
result = append(result, response.SimulatedOperatorCandidate{
|
|
UserID: p.UserID,
|
|
DisplayName: displayName,
|
|
TeamID: p.TeamID,
|
|
TeamName: teamNameMap[p.TeamID],
|
|
Eligible: skipReason == "",
|
|
SkipReasonCode: skipReason,
|
|
ActiveCount: int64(activeCounts[p.UserID]),
|
|
MaxConcurrentCount: p.MaxConcurrentCount,
|
|
ServiceStatus: int(p.ServiceStatus),
|
|
AutoAssignEnabled: p.AutoAssignEnabled,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, codes ...string) bool {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return false
|
|
}
|
|
if slices.Contains(operator.Roles, constants.RoleCodeSuperAdmin) || slices.Contains(operator.Roles, constants.RoleCodeAdmin) {
|
|
return true
|
|
}
|
|
for _, code := range codes {
|
|
if slices.Contains(operator.Permissions, code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageTeamsAndSchedules(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code) &&
|
|
s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageOperatorProfile(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(
|
|
operator,
|
|
constants.PermissionAgentCreate.Code,
|
|
constants.PermissionAgentUpdate.Code,
|
|
constants.PermissionAgentConfig.Code,
|
|
constants.PermissionAgentUpdateStatus.Code,
|
|
)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageAIAgents(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageChannels(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canRunQuickFixAction(operator *dto.AuthPrincipal, action string) bool {
|
|
switch action {
|
|
case QuickFixActionEnsureTeamSchedule:
|
|
return s.canManageTeamsAndSchedules(operator)
|
|
case QuickFixActionEnableMyProfile:
|
|
return s.canManageOperatorProfile(operator)
|
|
case QuickFixActionFixAIAgentHandoff:
|
|
return s.canManageAIAgents(operator)
|
|
case QuickFixActionEnsureDefaultChannel:
|
|
return s.canManageChannels(operator)
|
|
case QuickFixActionQuickSetupAll:
|
|
return s.canManageTeamsAndSchedules(operator) &&
|
|
s.canManageOperatorProfile(operator) &&
|
|
s.canManageAIAgents(operator) &&
|
|
s.canManageChannels(operator)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) filterStepQuickFixes(step response.SetupStepItem, operator *dto.AuthPrincipal) response.SetupStepItem {
|
|
filteredActions := make([]string, 0, len(step.QuickFixActions))
|
|
for _, action := range step.QuickFixActions {
|
|
if s.canRunQuickFixAction(operator, action) {
|
|
filteredActions = append(filteredActions, action)
|
|
}
|
|
}
|
|
step.QuickFixActions = filteredActions
|
|
|
|
for i := range step.Issues {
|
|
if step.Issues[i].QuickFixAction != "" && !s.canRunQuickFixAction(operator, step.Issues[i].QuickFixAction) {
|
|
step.Issues[i].QuickFixAction = ""
|
|
}
|
|
}
|
|
return step
|
|
}
|
|
|
|
func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error) {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return nil, errorsx.UnauthorizedI18n("error.setup.authRequired")
|
|
}
|
|
|
|
action := strings.TrimSpace(req.Action)
|
|
applied := make([]string, 0, 4)
|
|
|
|
switch action {
|
|
case QuickFixActionQuickSetupAll:
|
|
if !s.canRunQuickFixAction(operator, QuickFixActionQuickSetupAll) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
a1, err := s.ensureTeamAndSchedule(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a1...)
|
|
|
|
a2, err := s.enableOperatorProfile(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a2...)
|
|
|
|
a3, err := s.fixAIAgentHandoff(req.EntityID, operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a3...)
|
|
|
|
a4, err := s.ensureDefaultChannel(req.EntityID, operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a4...)
|
|
|
|
case QuickFixActionEnsureTeamSchedule:
|
|
if !s.canRunQuickFixAction(operator, QuickFixActionEnsureTeamSchedule) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
a1, err := s.ensureTeamAndSchedule(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a1...)
|
|
|
|
case QuickFixActionEnableMyProfile:
|
|
if !s.canRunQuickFixAction(operator, QuickFixActionEnableMyProfile) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if s.canManageTeamsAndSchedules(operator) {
|
|
a1, err := s.ensureTeamAndSchedule(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a1...)
|
|
}
|
|
|
|
a2, err := s.enableOperatorProfile(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a2...)
|
|
|
|
case QuickFixActionFixAIAgentHandoff:
|
|
if !s.canRunQuickFixAction(operator, QuickFixActionFixAIAgentHandoff) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if s.canManageTeamsAndSchedules(operator) {
|
|
a1, err := s.ensureTeamAndSchedule(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a1...)
|
|
}
|
|
|
|
a2, err := s.fixAIAgentHandoff(req.EntityID, operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a2...)
|
|
|
|
case QuickFixActionEnsureDefaultChannel:
|
|
if !s.canRunQuickFixAction(operator, QuickFixActionEnsureDefaultChannel) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if s.canManageTeamsAndSchedules(operator) {
|
|
a1, err := s.ensureTeamAndSchedule(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a1...)
|
|
}
|
|
if s.canManageAIAgents(operator) {
|
|
a2, err := s.fixAIAgentHandoff(0, operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a2...)
|
|
}
|
|
|
|
a3, err := s.ensureDefaultChannel(req.EntityID, operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, a3...)
|
|
|
|
default:
|
|
return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", action)
|
|
}
|
|
|
|
// Trigger dispatch of any pending conversations now that routing is fixed
|
|
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
|
|
|
readiness, err := s.GetReadiness(operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &response.SetupQuickFixResponse{
|
|
AppliedActions: applied,
|
|
Readiness: *readiness,
|
|
}, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) ensureTeamAndSchedule(operator *dto.AuthPrincipal) ([]string, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
applied := make([]string, 0, 2)
|
|
|
|
var activeTeams []models.AgentTeam
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(activeTeams) == 0 {
|
|
var anyTeam models.AgentTeam
|
|
if err := db.Order("id ASC").First(&anyTeam).Error; err == nil && anyTeam.ID > 0 {
|
|
if !s.hasAnyPermission(operator, constants.PermissionAgentTeamUpdate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
anyTeam.Status = enums.StatusOk
|
|
anyTeam.UpdatedAt = now
|
|
anyTeam.UpdateUserID = operator.UserID
|
|
anyTeam.UpdateUserName = operator.Username
|
|
if err := db.Save(&anyTeam).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeTeams = append(activeTeams, anyTeam)
|
|
applied = append(applied, "enabled_team")
|
|
} else {
|
|
if !s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
newTeam := models.AgentTeam{
|
|
Name: "Default Support Team",
|
|
LeaderUserID: operator.UserID,
|
|
Status: enums.StatusOk,
|
|
Description: "Auto-created support team",
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: operator.UserID,
|
|
CreateUserName: operator.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: operator.UserID,
|
|
UpdateUserName: operator.Username,
|
|
},
|
|
}
|
|
if err := db.Create(&newTeam).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeTeams = append(activeTeams, newTeam)
|
|
applied = append(applied, "created_default_team")
|
|
}
|
|
}
|
|
|
|
for _, team := range activeTeams {
|
|
var count int64
|
|
if err := db.Model(&models.AgentTeamSchedule{}).
|
|
Where("team_id = ? AND status = ? AND start_at <= ? AND end_at > ?", team.ID, enums.StatusOk, now, now).
|
|
Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
if !s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
schedule := models.AgentTeamSchedule{
|
|
TeamID: team.ID,
|
|
StartAt: now.Add(-1 * time.Hour),
|
|
EndAt: now.AddDate(1, 0, 0),
|
|
Remark: "24/7 Continuous Shift (Auto-Setup)",
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: operator.UserID,
|
|
CreateUserName: operator.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: operator.UserID,
|
|
UpdateUserName: operator.Username,
|
|
},
|
|
}
|
|
if err := db.Create(&schedule).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "created_active_schedule")
|
|
}
|
|
}
|
|
|
|
return applied, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) enableOperatorProfile(operator *dto.AuthPrincipal) ([]string, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
applied := make([]string, 0, 1)
|
|
|
|
var firstTeam models.AgentTeam
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstTeam).Error; err != nil {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
var profile models.AgentProfile
|
|
err := db.Where("user_id = ?", operator.UserID).First(&profile).Error
|
|
if err != nil {
|
|
if !s.hasAnyPermission(operator, constants.PermissionAgentCreate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
displayName := firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID))
|
|
newProfile := models.AgentProfile{
|
|
UserID: operator.UserID,
|
|
TeamID: firstTeam.ID,
|
|
AgentCode: fmt.Sprintf("AG%04d", operator.UserID),
|
|
DisplayName: displayName,
|
|
ServiceStatus: enums.ServiceStatusIdle,
|
|
MaxConcurrentCount: 10,
|
|
PriorityLevel: 1,
|
|
AutoAssignEnabled: true,
|
|
ReceiveOfflineMessage: true,
|
|
LastOnlineAt: &now,
|
|
LastStatusAt: &now,
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: operator.UserID,
|
|
CreateUserName: operator.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: operator.UserID,
|
|
UpdateUserName: operator.Username,
|
|
},
|
|
}
|
|
if err := db.Create(&newProfile).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "created_operator_profile")
|
|
return applied, nil
|
|
}
|
|
|
|
targetTeamID := profile.TeamID
|
|
var teamCheck models.AgentTeam
|
|
needsTeamRebind := targetTeamID <= 0 || db.Where("id = ? AND status = ?", targetTeamID, enums.StatusOk).First(&teamCheck).Error != nil
|
|
if needsTeamRebind {
|
|
targetTeamID = firstTeam.ID
|
|
}
|
|
|
|
maxConcurrent := profile.MaxConcurrentCount
|
|
needsConfigChange := !profile.AutoAssignEnabled || maxConcurrent <= 0
|
|
if maxConcurrent <= 0 {
|
|
maxConcurrent = 10
|
|
}
|
|
needsProfileEnable := profile.Status != enums.StatusOk
|
|
|
|
if (needsTeamRebind || needsProfileEnable) && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if needsConfigChange && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
updates := map[string]any{
|
|
"team_id": targetTeamID,
|
|
"service_status": enums.ServiceStatusIdle,
|
|
"auto_assign_enabled": true,
|
|
"max_concurrent_count": maxConcurrent,
|
|
"status": enums.StatusOk,
|
|
"last_online_at": now,
|
|
"last_status_at": now,
|
|
"updated_at": now,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
}
|
|
if err := db.Model(&models.AgentProfile{}).Where("id = ?", profile.ID).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "updated_operator_profile_online")
|
|
return applied, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) fixAIAgentHandoff(targetAgentID int64, operator *dto.AuthPrincipal) ([]string, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
applied := make([]string, 0, 2)
|
|
|
|
var activeTeams []models.AgentTeam
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
teamIDStrs := make([]string, 0, len(activeTeams))
|
|
activeTeamSet := make(map[int64]bool, len(activeTeams))
|
|
for _, t := range activeTeams {
|
|
teamIDStrs = append(teamIDStrs, strconv.FormatInt(t.ID, 10))
|
|
activeTeamSet[t.ID] = true
|
|
}
|
|
defaultTeamIDsCSV := strings.Join(teamIDStrs, ",")
|
|
|
|
var agents []models.AIAgent
|
|
if targetAgentID > 0 {
|
|
db.Where("id = ?", targetAgentID).Find(&agents)
|
|
} else {
|
|
db.Order("id ASC").Find(&agents)
|
|
}
|
|
|
|
if len(agents) == 0 {
|
|
if !s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
newAgent := models.AIAgent{
|
|
Name: "Default Reception Agent",
|
|
Description: "Routes customer inquiries to human operators",
|
|
Status: enums.StatusOk,
|
|
ServiceMode: enums.IMConversationServiceModeHumanOnly,
|
|
RolloutPercent: 100,
|
|
TeamIDs: defaultTeamIDsCSV,
|
|
HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool,
|
|
FallbackMode: enums.AIAgentFallbackModeHandoff,
|
|
AutoHandoffOnFailure: true,
|
|
ReplyTimeoutSeconds: 180,
|
|
MaxSteps: 6,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: operator.UserID,
|
|
CreateUserName: operator.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: operator.UserID,
|
|
UpdateUserName: operator.Username,
|
|
},
|
|
}
|
|
if err := db.Create(&newAgent).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "created_default_ai_agent")
|
|
return applied, nil
|
|
}
|
|
|
|
if !s.hasAnyPermission(operator, constants.PermissionAIAgentUpdate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
for _, agent := range agents {
|
|
existingTeamIDs := utils.SplitInt64s(agent.TeamIDs)
|
|
validTeamIDs := make([]string, 0, len(existingTeamIDs))
|
|
for _, tid := range existingTeamIDs {
|
|
if activeTeamSet[tid] {
|
|
validTeamIDs = append(validTeamIDs, strconv.FormatInt(tid, 10))
|
|
}
|
|
}
|
|
newTeamCSV := strings.Join(validTeamIDs, ",")
|
|
if newTeamCSV == "" {
|
|
newTeamCSV = defaultTeamIDsCSV
|
|
}
|
|
|
|
hasValidAIConfig := false
|
|
if agent.AIConfigID > 0 {
|
|
var aiCfg models.AIConfig
|
|
if err := db.Where("id = ? AND status = ?", agent.AIConfigID, enums.StatusOk).First(&aiCfg).Error; err == nil && aiCfg.ID > 0 {
|
|
hasValidAIConfig = true
|
|
}
|
|
}
|
|
|
|
newServiceMode := agent.ServiceMode
|
|
if !hasValidAIConfig {
|
|
newServiceMode = enums.IMConversationServiceModeHumanOnly
|
|
} else if newServiceMode == enums.IMConversationServiceModeAIOnly || newServiceMode == 0 {
|
|
newServiceMode = enums.IMConversationServiceModeAIFirst
|
|
}
|
|
|
|
rollout := agent.RolloutPercent
|
|
if rollout <= 0 {
|
|
rollout = 100
|
|
}
|
|
|
|
updates := map[string]any{
|
|
"status": enums.StatusOk,
|
|
"team_ids": newTeamCSV,
|
|
"service_mode": newServiceMode,
|
|
"auto_handoff_on_failure": true,
|
|
"rollout_percent": rollout,
|
|
"updated_at": now,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
}
|
|
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "updated_ai_agent_handoff")
|
|
}
|
|
|
|
return applied, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) ensureDefaultChannel(targetChannelID int64, operator *dto.AuthPrincipal) ([]string, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
applied := make([]string, 0, 1)
|
|
|
|
var firstAgent models.AIAgent
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstAgent).Error; err != nil {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
var channels []models.Channel
|
|
if targetChannelID > 0 {
|
|
db.Where("id = ?", targetChannelID).Find(&channels)
|
|
} else {
|
|
db.Order("id ASC").Find(&channels)
|
|
}
|
|
|
|
if len(channels) == 0 {
|
|
if !s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
newChannel := models.Channel{
|
|
Name: "Default Web Support Channel",
|
|
ChannelType: string(enums.ChannelTypeWeb),
|
|
ChannelID: fmt.Sprintf("web_default_%d", now.Unix()),
|
|
AIAgentID: firstAgent.ID,
|
|
AIAgentRolloutPercent: 100,
|
|
ConfigJSON: `{"position":"right"}`,
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: operator.UserID,
|
|
CreateUserName: operator.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: operator.UserID,
|
|
UpdateUserName: operator.Username,
|
|
},
|
|
}
|
|
if err := db.Create(&newChannel).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "created_default_channel")
|
|
return applied, nil
|
|
}
|
|
|
|
if !s.hasAnyPermission(operator, constants.PermissionChannelUpdate.Code) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
|
|
for _, ch := range channels {
|
|
agentID := ch.AIAgentID
|
|
var checkAgent models.AIAgent
|
|
if agentID <= 0 || db.Where("id = ? AND status = ?", agentID, enums.StatusOk).First(&checkAgent).Error != nil {
|
|
agentID = firstAgent.ID
|
|
}
|
|
rollout := ch.AIAgentRolloutPercent
|
|
if rollout <= 0 {
|
|
rollout = 100
|
|
}
|
|
updates := map[string]any{
|
|
"status": enums.StatusOk,
|
|
"ai_agent_id": agentID,
|
|
"ai_agent_rollout_percent": rollout,
|
|
"updated_at": now,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
}
|
|
if err := db.Model(&models.Channel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
applied = append(applied, "updated_channel_routing")
|
|
}
|
|
|
|
return applied, nil
|
|
}
|
|
|
|
func extractProfileUserIDs(profiles []models.AgentProfile) []int64 {
|
|
ids := make([]int64, 0, len(profiles))
|
|
seen := make(map[int64]bool, len(profiles))
|
|
for _, p := range profiles {
|
|
if p.UserID > 0 && !seen[p.UserID] {
|
|
seen[p.UserID] = true
|
|
ids = append(ids, p.UserID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
trimmed := strings.TrimSpace(v)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
_ = math.Max
|
|
_ = slices.SortFunc[[]int, int]
|
|
)
|