thread-master/apps/backend/internal/module/crm/usecase/stats.go

235 lines
6.4 KiB
Go
Raw Permalink Normal View History

2026-08-19 02:01:15 +00:00
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"
// DimensionTermsDimensionSources 需要 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 轉換統計fromto 任一邊為 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
}