Initial qiwei secondary development handoff
This commit is contained in:
410
helper/after_sales_dispatch_test.go
Normal file
410
helper/after_sales_dispatch_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user