package usecase import ( "context" "testing" "apps/backend/internal/module/crm/domain" "apps/backend/internal/module/crm/repository" radarDomain "apps/backend/internal/module/radar/domain" ) type stubRadarOpps struct{ opps []*radarDomain.Opportunity } func (s stubRadarOpps) GetOpportunity(_ context.Context, id string) (*radarDomain.Opportunity, error) { for _, o := range s.opps { if o.ID == id { return o, nil } } return nil, radarDomain.ErrNotFound } func (s stubRadarOpps) ListOpportunities( _ context.Context, ownerUID int64, f radarDomain.OpportunityListFilter, ) ([]*radarDomain.Opportunity, int64, error) { matched := make([]*radarDomain.Opportunity, 0, len(s.opps)) for _, o := range s.opps { if o.OwnerUID != ownerUID || o.Status != radarDomain.OppAccepted { continue } if f.CreatedFrom > 0 && o.CreatedAt < f.CreatedFrom { continue } if f.CreatedTo > 0 && o.CreatedAt > f.CreatedTo { continue } matched = append(matched, o) } return matched, int64(len(matched)), nil } func seedStatsContact(t *testing.T, repo domain.Repository, handle, stage string, at int64) *domain.Contact { t.Helper() c, err := repo.UpsertContactByIdentity(context.Background(), &domain.Contact{ OwnerUID: 7, AuthorHandle: handle, Stage: stage, LastTouchAt: at, }) if err != nil { t.Fatal(err) } c.Stage = stage c.LastTouchAt = at if err := repo.SaveContact(context.Background(), c); err != nil { t.Fatal(err) } return c } func acceptedOpp(id, contactID, source string, createdAt int64, terms ...string) *radarDomain.Opportunity { return &radarDomain.Opportunity{ ID: id, OwnerUID: 7, Status: radarDomain.OppAccepted, Source: source, ContactID: contactID, MatchedTerms: terms, CreatedAt: createdAt, } } func TestStatsAttributesTermsAndSources(t *testing.T) { ctx := context.Background() repo := repository.NewMemory() svc := New(repo) won := seedStatsContact(t, repo, "won-buyer", domain.StageWon, 100) replied := seedStatsContact(t, repo, "replied-buyer", domain.StageReplied, 100) cold := seedStatsContact(t, repo, "cold-buyer", domain.StageNewFound, 100) svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{ acceptedOpp("o1", won.ID, radarDomain.OppSourceThreads, 100, "婚攝"), // 同一聯絡人同一關鍵字的第二筆商機不可重複計入 accepted acceptedOpp("o2", won.ID, radarDomain.OppSourceThreads, 101, "婚攝"), acceptedOpp("o3", replied.ID, radarDomain.OppSourceThreads, 100, "婚攝"), acceptedOpp("o4", cold.ID, radarDomain.OppSourceScoutPromote, 100, "外包"), }} got, err := svc.Stats(ctx, 7, 0, 0) if err != nil { t.Fatal(err) } terms := map[string]ConversionStat{} for _, s := range got.Terms { terms[s.Key] = s } if s := terms["婚攝"]; s.Accepted != 2 || s.Replied != 2 || s.Won != 1 { t.Fatalf("婚攝 = %+v, want accepted 2 / replied 2 / won 1", s) } if s := terms["外包"]; s.Accepted != 1 || s.Replied != 0 || s.Won != 0 { t.Fatalf("外包 = %+v", s) } sources := map[string]ConversionStat{} for _, s := range got.Sources { sources[s.Key] = s } if s := sources["radar"]; s.Accepted != 2 || s.Won != 1 { t.Fatalf("radar source = %+v", s) } if s := sources["scout"]; s.Accepted != 1 { t.Fatalf("scout source = %+v", s) } } // 回覆版本維度沒有資料模型支援,必須明說不可用而不是回空清單。 func TestStatsFlagsVariantsUnavailable(t *testing.T) { svc := New(repository.NewMemory()) svc.RadarOpps = stubRadarOpps{} got, err := svc.Stats(context.Background(), 7, 0, 0) if err != nil { t.Fatal(err) } if !hasDimension(got.UnavailableDimensions, DimensionVariants) { t.Fatalf("variants must be reported unavailable, got %v", got.UnavailableDimensions) } if hasDimension(got.UnavailableDimensions, DimensionTerms) { t.Fatalf("terms is computable when radar is wired, got %v", got.UnavailableDimensions) } } func TestStatsFlagsTermsUnavailableWithoutRadar(t *testing.T) { svc := New(repository.NewMemory()) got, err := svc.Stats(context.Background(), 7, 0, 0) if err != nil { t.Fatal(err) } for _, dim := range []string{DimensionVariants, DimensionTerms, DimensionSources} { if !hasDimension(got.UnavailableDimensions, dim) { t.Fatalf("%s must be unavailable without radar, got %v", dim, got.UnavailableDimensions) } } } func TestStatsHonoursDateRange(t *testing.T) { ctx := context.Background() repo := repository.NewMemory() svc := New(repo) old := seedStatsContact(t, repo, "old-buyer", domain.StageWon, 50) recent := seedStatsContact(t, repo, "recent-buyer", domain.StageWon, 500) svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{ acceptedOpp("o1", old.ID, radarDomain.OppSourceThreads, 50, "舊詞"), acceptedOpp("o2", recent.ID, radarDomain.OppSourceThreads, 500, "新詞"), }} got, err := svc.Stats(ctx, 7, 400, 600) if err != nil { t.Fatal(err) } if got.TotalContacts != 1 || got.ByStage[domain.StageWon] != 1 { t.Fatalf("range should keep only the recent contact, got %+v", got) } if len(got.Terms) != 1 || got.Terms[0].Key != "新詞" { t.Fatalf("range should keep only the recent term, got %+v", got.Terms) } } func hasDimension(list []string, want string) bool { for _, v := range list { if v == want { return true } } return false }