411 lines
15 KiB
Go
411 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"qiweimanager/config"
|
|
)
|
|
|
|
func TestAfterSalesDispatchRulePriority(t *testing.T) {
|
|
issue := AfterSalesIssue{
|
|
ConversationID: "R:vip-room",
|
|
RoomName: "热成像售后群",
|
|
CustomerName: "华南客户",
|
|
IssueContent: "设备启动报错",
|
|
AISuggestion: "建议检查线缆",
|
|
}
|
|
cfg := AfterSalesDispatchConfig{
|
|
Rules: []AfterSalesDispatchRule{
|
|
{ID: "issue", Name: "报错", EngineerUserID: "engineer-issue", IssueKeywords: []string{"报错"}, Enabled: true},
|
|
{ID: "product", Name: "热成像", EngineerUserID: "engineer-product", ProductKeywords: []string{"热成像"}, Enabled: true},
|
|
{ID: "room", Name: "VIP群", EngineerUserID: "engineer-room", ConversationIDs: []string{"R:vip-room"}, Enabled: true},
|
|
},
|
|
}
|
|
match, ok := matchAfterSalesDispatchRule(issue, cfg)
|
|
if !ok {
|
|
t.Fatal("expected a dispatch match")
|
|
}
|
|
if match.Rule.EngineerUserID != "engineer-room" {
|
|
t.Fatalf("expected conversation/customer rule to win, got %#v", match)
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesDispatchNoMatchStaysUnassigned(t *testing.T) {
|
|
issue := AfterSalesIssue{IssueContent: "普通咨询"}
|
|
cfg := AfterSalesDispatchConfig{
|
|
Rules: []AfterSalesDispatchRule{{ID: "r1", EngineerUserID: "engineer", ProductKeywords: []string{"热成像"}, Enabled: true}},
|
|
}
|
|
if match, ok := matchAfterSalesDispatchRule(issue, cfg); ok {
|
|
t.Fatalf("expected no match, got %#v", match)
|
|
}
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "普通咨询"}}}
|
|
engine.applyLegacyDispatchSuggestionsLocked(cfg)
|
|
if engine.issues[0].DispatchStatus != afterSalesDispatchUnassigned {
|
|
t.Fatalf("expected unassigned, got %s", engine.issues[0].DispatchStatus)
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesAIDispatchHighConfidenceAssignsEngineer(t *testing.T) {
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusPending,
|
|
IssueContent: "热成像镜头无法调焦",
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
}}}
|
|
cfg := AfterSalesDispatchConfig{
|
|
AutoNotifyMinConfidence: 0.75,
|
|
Engineers: []AfterSalesEngineer{{
|
|
UserID: "engineer-a",
|
|
Name: "张工",
|
|
Description: "负责热成像镜头调焦问题",
|
|
Enabled: true,
|
|
}},
|
|
}
|
|
normalizeAfterSalesDispatchConfig(&cfg)
|
|
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{
|
|
EngineerUserID: "engineer-a",
|
|
Reason: "热成像镜头调焦属于张工职责",
|
|
Confidence: 0.92,
|
|
}, nil)
|
|
got := engine.issues[0]
|
|
if got.AssignedEngineerID != "engineer-a" || got.DispatchStatus != afterSalesDispatchSuggested || got.DispatchSource != dispatchSourceAI {
|
|
t.Fatalf("expected AI suggested engineer, got %#v", got)
|
|
}
|
|
if got.DispatchConfidence != 0.92 {
|
|
t.Fatalf("expected confidence 0.92, got %v", got.DispatchConfidence)
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesAIDispatchLowConfidenceStaysUnassigned(t *testing.T) {
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusPending,
|
|
IssueContent: "客户说有点奇怪",
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
}}}
|
|
cfg := AfterSalesDispatchConfig{
|
|
AutoNotifyMinConfidence: 0.75,
|
|
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}},
|
|
}
|
|
normalizeAfterSalesDispatchConfig(&cfg)
|
|
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{
|
|
EngineerUserID: "engineer-a",
|
|
Reason: "不够确定",
|
|
Confidence: 0.45,
|
|
}, nil)
|
|
got := engine.issues[0]
|
|
if got.AssignedEngineerID != "" || got.DispatchStatus != afterSalesDispatchUnassigned {
|
|
t.Fatalf("expected unassigned low-confidence issue, got %#v", got)
|
|
}
|
|
if got.DispatchConfidence != 0.45 {
|
|
t.Fatalf("expected stored low confidence, got %v", got.DispatchConfidence)
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesManualAssignmentNotOverwrittenByAI(t *testing.T) {
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusPending,
|
|
AssignedEngineerID: "manual-engineer",
|
|
DispatchStatus: afterSalesDispatchAssigned,
|
|
DispatchSource: dispatchSourceManual,
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
}}}
|
|
cfg := AfterSalesDispatchConfig{
|
|
AutoNotifyMinConfidence: 0.75,
|
|
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头", Enabled: true}},
|
|
}
|
|
normalizeAfterSalesDispatchConfig(&cfg)
|
|
engine.applyDispatchChoice("i1", cfg, afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Confidence: 0.99}, nil)
|
|
if engine.issues[0].AssignedEngineerID != "manual-engineer" || engine.issues[0].DispatchSource != dispatchSourceManual {
|
|
t.Fatalf("manual assignment was overwritten: %#v", engine.issues[0])
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesAutoNotifyEnabledSendsHighConfidenceRecommendation(t *testing.T) {
|
|
cleanupDispatchConfig(t)
|
|
oldMatcher := afterSalesDispatchMatcher
|
|
oldConfigSource := afterSalesDispatchAIConfigSource
|
|
oldSender := sendAutoReplyTextSender
|
|
afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
|
|
return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil
|
|
}
|
|
afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) {
|
|
return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil
|
|
}
|
|
sent := 0
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
sent++
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
afterSalesDispatchMatcher = oldMatcher
|
|
afterSalesDispatchAIConfigSource = oldConfigSource
|
|
sendAutoReplyTextSender = oldSender
|
|
})
|
|
clientIdMutex.Lock()
|
|
oldGlobalClientID := globalClientId
|
|
oldGlobalClientMap := globalClientMap
|
|
globalClientId = 7
|
|
globalClientMap = map[uint32]string{7: "robot-user"}
|
|
clientIdMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
clientIdMutex.Lock()
|
|
globalClientId = oldGlobalClientID
|
|
globalClientMap = oldGlobalClientMap
|
|
clientIdMutex.Unlock()
|
|
})
|
|
|
|
cfg := AfterSalesDispatchConfig{
|
|
AutoNotifyEnabled: true,
|
|
AutoNotifyMinConfidence: 0.75,
|
|
NotifyCooldownSeconds: 1,
|
|
Engineers: []AfterSalesEngineer{{
|
|
UserID: "engineer-a",
|
|
Name: "张工",
|
|
Description: "负责镜头问题",
|
|
Enabled: true,
|
|
}},
|
|
}
|
|
normalizeAfterSalesDispatchConfig(&cfg)
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusPending,
|
|
IssueContent: "镜头无法调焦",
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
}}}
|
|
engine.refreshDispatchAssignments(cfg)
|
|
if sent != 1 {
|
|
t.Fatalf("expected one automatic notification, got %d", sent)
|
|
}
|
|
if engine.issues[0].NotifyStatus != afterSalesNotifySent {
|
|
t.Fatalf("expected sent issue, got %#v", engine.issues[0])
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesAutoNotifyDisabledDoesNotSend(t *testing.T) {
|
|
oldMatcher := afterSalesDispatchMatcher
|
|
oldConfigSource := afterSalesDispatchAIConfigSource
|
|
oldSender := sendAutoReplyTextSender
|
|
afterSalesDispatchMatcher = func(aiCfg config.AIConfig, issue AfterSalesIssue, engineers []AfterSalesEngineer) (afterSalesDispatchAIChoice, error) {
|
|
return afterSalesDispatchAIChoice{EngineerUserID: "engineer-a", Reason: "职责匹配", Confidence: 0.91}, nil
|
|
}
|
|
afterSalesDispatchAIConfigSource = func() (config.AIConfig, error) {
|
|
return config.AIConfig{BaseURL: "http://127.0.0.1", Model: "test"}, nil
|
|
}
|
|
sent := 0
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
sent++
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
afterSalesDispatchMatcher = oldMatcher
|
|
afterSalesDispatchAIConfigSource = oldConfigSource
|
|
sendAutoReplyTextSender = oldSender
|
|
})
|
|
|
|
cfg := AfterSalesDispatchConfig{
|
|
AutoNotifyEnabled: false,
|
|
AutoNotifyMinConfidence: 0.75,
|
|
Engineers: []AfterSalesEngineer{{UserID: "engineer-a", Description: "负责镜头问题", Enabled: true}},
|
|
}
|
|
normalizeAfterSalesDispatchConfig(&cfg)
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "镜头无法调焦", NotifyStatus: afterSalesNotifyNotSent}}}
|
|
engine.refreshDispatchAssignments(cfg)
|
|
if sent != 0 {
|
|
t.Fatalf("expected no automatic notification when disabled, got %d", sent)
|
|
}
|
|
if engine.issues[0].AssignedEngineerID != "engineer-a" || engine.issues[0].NotifyStatus != afterSalesNotifyNotSent {
|
|
t.Fatalf("expected recommendation without notification, got %#v", engine.issues[0])
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesNotifyCooldownSkipsDuplicateSend(t *testing.T) {
|
|
cleanupDispatchConfig(t)
|
|
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
|
|
NotifyCooldownSeconds: 600,
|
|
}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
sent := 0
|
|
oldSender := sendAutoReplyTextSender
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
sent++
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
|
|
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusPending,
|
|
AssignedEngineerID: "engineer-a",
|
|
DispatchStatus: afterSalesDispatchAssigned,
|
|
NotifyStatus: afterSalesNotifySent,
|
|
LastNotifiedAt: time.Now().Unix(),
|
|
}}}
|
|
result := engine.notifyEngineer("i1")
|
|
if result.Success || !strings.Contains(result.Message, "冷却") {
|
|
t.Fatalf("expected cooldown failure, got %#v", result)
|
|
}
|
|
if sent != 0 {
|
|
t.Fatalf("expected no send during cooldown, got %d", sent)
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesNotifyResolvedIssueDoesNotSend(t *testing.T) {
|
|
cleanupDispatchConfig(t)
|
|
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
|
|
NotifyCooldownSeconds: 1,
|
|
}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
sent := 0
|
|
oldSender := sendAutoReplyTextSender
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
sent++
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
|
|
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{
|
|
ID: "i1",
|
|
Status: afterSalesIssueStatusResolved,
|
|
AssignedEngineerID: "engineer-a",
|
|
DispatchStatus: afterSalesDispatchAssigned,
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
}}}
|
|
result := engine.notifyEngineer("i1")
|
|
if result.Success || !strings.Contains(result.Message, "非待处理") {
|
|
t.Fatalf("expected non-pending failure, got %#v", result)
|
|
}
|
|
if sent != 0 {
|
|
t.Fatalf("expected no send for resolved issue, got %d", sent)
|
|
}
|
|
if engine.issues[0].NotifyStatus != afterSalesNotifyNotSent {
|
|
t.Fatalf("expected notify status unchanged, got %#v", engine.issues[0])
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesBatchNotifyPartialFailure(t *testing.T) {
|
|
cleanupDispatchConfig(t)
|
|
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{
|
|
NotifyCooldownSeconds: 1,
|
|
}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
oldSender := sendAutoReplyTextSender
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
if clientID != 7 || conversationID != "S:robot-user_engineer-a" {
|
|
t.Fatalf("unexpected send target client=%d conversation=%s", clientID, conversationID)
|
|
}
|
|
if !strings.Contains(content, "售后问题待处理") || !strings.Contains(content, "i1") {
|
|
t.Fatalf("unexpected notification content: %s", content)
|
|
}
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
|
|
|
|
clientIdMutex.Lock()
|
|
oldGlobalClientID := globalClientId
|
|
oldGlobalClientMap := globalClientMap
|
|
globalClientId = 7
|
|
globalClientMap = map[uint32]string{7: "robot-user"}
|
|
clientIdMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
clientIdMutex.Lock()
|
|
globalClientId = oldGlobalClientID
|
|
globalClientMap = oldGlobalClientMap
|
|
clientIdMutex.Unlock()
|
|
})
|
|
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{
|
|
{
|
|
ID: "i1",
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
Status: afterSalesIssueStatusPending,
|
|
IssueContent: "设备报错",
|
|
AssignedEngineerID: "engineer-a",
|
|
AssignedEngineerName: "张工",
|
|
DispatchStatus: afterSalesDispatchAssigned,
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
},
|
|
{
|
|
ID: "i2",
|
|
Status: afterSalesIssueStatusPending,
|
|
IssueContent: "未分配问题",
|
|
NotifyStatus: afterSalesNotifyNotSent,
|
|
},
|
|
}}
|
|
result := engine.batchNotifyEngineers([]string{"i1", "i2"})
|
|
if result["success"].(bool) {
|
|
t.Fatalf("expected partial failure, got %#v", result)
|
|
}
|
|
data := result["data"].(map[string]interface{})
|
|
if data["successCount"].(int) != 1 || data["totalCount"].(int) != 2 {
|
|
t.Fatalf("unexpected batch data: %#v", data)
|
|
}
|
|
if engine.issues[0].NotifyStatus != afterSalesNotifySent {
|
|
t.Fatalf("expected first issue sent, got %#v", engine.issues[0])
|
|
}
|
|
if engine.issues[1].NotifyStatus != afterSalesNotifyFailed {
|
|
t.Fatalf("expected second issue failed, got %#v", engine.issues[1])
|
|
}
|
|
}
|
|
|
|
func TestAfterSalesBatchNotifyEmptyMeansAllAssignedUnsent(t *testing.T) {
|
|
cleanupDispatchConfig(t)
|
|
if err := saveAfterSalesDispatchConfig(AfterSalesDispatchConfig{NotifyCooldownSeconds: 1}); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
sent := make([]string, 0)
|
|
oldSender := sendAutoReplyTextSender
|
|
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
|
sent = append(sent, content)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { sendAutoReplyTextSender = oldSender })
|
|
|
|
clientIdMutex.Lock()
|
|
oldGlobalClientID := globalClientId
|
|
oldGlobalClientMap := globalClientMap
|
|
globalClientId = 7
|
|
globalClientMap = map[uint32]string{7: "robot-user"}
|
|
clientIdMutex.Unlock()
|
|
t.Cleanup(func() {
|
|
clientIdMutex.Lock()
|
|
globalClientId = oldGlobalClientID
|
|
globalClientMap = oldGlobalClientMap
|
|
clientIdMutex.Unlock()
|
|
})
|
|
|
|
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{
|
|
{ID: "send-me", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent},
|
|
{ID: "skip-unassigned", Status: afterSalesIssueStatusPending, NotifyStatus: afterSalesNotifyNotSent},
|
|
{ID: "skip-sent", Status: afterSalesIssueStatusPending, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifySent},
|
|
{ID: "skip-resolved", Status: afterSalesIssueStatusResolved, AssignedEngineerID: "engineer-a", NotifyStatus: afterSalesNotifyNotSent},
|
|
}}
|
|
result := engine.batchNotifyEngineers(nil)
|
|
if !result["success"].(bool) {
|
|
t.Fatalf("expected all eligible notifications to succeed, got %#v", result)
|
|
}
|
|
data := result["data"].(map[string]interface{})
|
|
if data["successCount"].(int) != 1 || data["totalCount"].(int) != 1 {
|
|
t.Fatalf("unexpected all-notify data: %#v", data)
|
|
}
|
|
if len(sent) != 1 || !strings.Contains(sent[0], "send-me") {
|
|
t.Fatalf("expected only one notification for send-me, got %#v", sent)
|
|
}
|
|
}
|
|
|
|
func cleanupDispatchConfig(t *testing.T) {
|
|
t.Helper()
|
|
path := afterSalesDispatchConfigPath()
|
|
t.Cleanup(func() {
|
|
_ = os.Remove(path)
|
|
})
|
|
_ = os.Remove(path)
|
|
}
|