package usecase import ( "context" "sort" "apps/backend/internal/module/crm/domain" radarDomain "apps/backend/internal/module/radar/domain" ) // StatsMinSample 是能公布比率的最低樣本數(spec §9.5:樣本 < 5 只給絕對數)。 const StatsMinSample = 5 // DimensionVariants 目前無法計算:回覆版本與成交之間還沒有歸因欄位, // 硬回空陣列會讓前端把功能缺口顯示成「使用者還沒有資料」。 const DimensionVariants = "variants" // DimensionTerms/DimensionSources 需要 radar 商機才能歸因。 const ( DimensionTerms = "terms" DimensionSources = "sources" ) // ConversionStat 只帶絕對數;比率是否揭露由樣本門檻決定。 type ConversionStat struct { Key string Accepted int Replied int Won int } // StatsResult 是 CRM 轉換統計。UnavailableDimensions 明確標出「尚未實作/無法計算」 // 的維度,讓呼叫端能跟「查得到但沒有資料」區分開來。 type StatsResult struct { TotalContacts int64 ByStage map[string]int NeedsFollowUp int Terms []ConversionStat Sources []ConversionStat UnavailableDimensions []string } /* Stats 聚合 CRM 轉換統計;from/to 任一邊為 0 代表該側不設限。 兩種維度的時間語意不同,因為問的問題不同: - 名單分佈看聯絡人「最近活動時間」落在區間內。 - 關鍵字/來源看商機的「建立時間」落在區間內,成交與否則取該聯絡人的現況階段。 */ func (s *Service) Stats(ctx context.Context, ownerUID int64, from, to int64) (*StatsResult, error) { out := &StatsResult{ ByStage: map[string]int{}, Terms: []ConversionStat{}, Sources: []ConversionStat{}, // 回覆版本維度缺資料模型支援,永遠標為不可用。 UnavailableDimensions: []string{DimensionVariants}, } stageByContact, err := s.collectContactStages(ctx, ownerUID, from, to, out) if err != nil { return nil, err } out.ByStage["needs_follow_up"] = out.NeedsFollowUp if s.RadarOpps == nil { out.UnavailableDimensions = append(out.UnavailableDimensions, DimensionTerms, DimensionSources) return out, nil } terms, sources, err := s.attributeOpportunities(ctx, ownerUID, from, to, stageByContact) if err != nil { return nil, err } out.Terms, out.Sources = sortedStats(terms), sortedStats(sources) return out, nil } // collectContactStages 走訪全部聯絡人:區間內的計入名單分佈, // 同時建立 contact → stage 對照供商機歸因使用(避免逐筆回查)。 func (s *Service) collectContactStages( ctx context.Context, ownerUID, from, to int64, out *StatsResult, ) (map[string]string, error) { const pageSize = 500 stageByContact := map[string]string{} scanned := 0 for page := 1; ; page++ { list, total, err := s.Repo.ListContacts(ctx, ownerUID, domain.ContactListFilter{ Page: page, PageSize: pageSize, }) if err != nil { return nil, err } scanned += len(list) for _, c := range list { stageByContact[c.ID] = c.Stage if !withinRange(contactActivityAt(c), from, to) { continue } out.ByStage[c.Stage]++ if c.NeedsFollowUp { out.NeedsFollowUp++ } out.TotalContacts++ } if len(list) == 0 || int64(scanned) >= total { break } } return stageByContact, nil } // attributeOpportunities 把已接受的商機分攤到關鍵字與來源兩個維度。 // 同一個聯絡人在同一關鍵字下只算一次,否則多筆商機會灌大 accepted。 func (s *Service) attributeOpportunities( ctx context.Context, ownerUID, from, to int64, stageByContact map[string]string, ) (map[string]*ConversionStat, map[string]*ConversionStat, error) { const pageSize = 200 terms := map[string]*ConversionStat{} sources := map[string]*ConversionStat{} countedTerm := map[string]bool{} countedSource := map[string]bool{} scanned := 0 for page := 1; ; page++ { list, total, err := s.RadarOpps.ListOpportunities(ctx, ownerUID, radarDomain.OpportunityListFilter{ Statuses: []string{radarDomain.OppAccepted}, CreatedFrom: from, CreatedTo: to, Page: page, PageSize: pageSize, }) if err != nil { return nil, nil, err } scanned += len(list) for _, o := range list { if o == nil || o.ContactID == "" { continue } stage, ok := stageByContact[o.ContactID] if !ok { continue } for _, term := range o.MatchedTerms { if term == "" || countedTerm[o.ContactID+"\x00"+term] { continue } countedTerm[o.ContactID+"\x00"+term] = true tally(terms, term, stage) } source := conversionSource(o.Source) if !countedSource[o.ContactID+"\x00"+source] { countedSource[o.ContactID+"\x00"+source] = true tally(sources, source, stage) } } if len(list) == 0 || int64(scanned) >= total { break } } return terms, sources, nil } func tally(into map[string]*ConversionStat, key, stage string) { stat := into[key] if stat == nil { stat = &ConversionStat{Key: key} into[key] = stat } stat.Accepted++ if stageReachedReply(stage) { stat.Replied++ } if stage == domain.StageWon { stat.Won++ } } // stageReachedReply:現況階段已走到「對方回覆」之後才算 replied。 func stageReachedReply(stage string) bool { switch stage { case domain.StageReplied, domain.StageQuoted, domain.StageWon: return true } return false } // conversionSource 把商機來源翻成統計維度的來源名稱。 func conversionSource(oppSource string) string { switch oppSource { case radarDomain.OppSourceScoutPromote: return "scout" case radarDomain.OppSourceManualImport: return "manual_import" default: return "radar" } } func contactActivityAt(c *domain.Contact) int64 { if c.LastTouchAt > 0 { return c.LastTouchAt } if c.UpdatedAt > 0 { return c.UpdatedAt } return c.CreatedAt } func withinRange(at, from, to int64) bool { if from > 0 && at < from { return false } if to > 0 && at > to { return false } return true } // sortedStats 以成交數→接受數→名稱排序,讓輸出穩定且高價值的排前面。 func sortedStats(in map[string]*ConversionStat) []ConversionStat { out := make([]ConversionStat, 0, len(in)) for _, stat := range in { out = append(out, *stat) } sort.Slice(out, func(i, j int) bool { if out[i].Won != out[j].Won { return out[i].Won > out[j].Won } if out[i].Accepted != out[j].Accepted { return out[i].Accepted > out[j].Accepted } return out[i].Key < out[j].Key }) return out }