235 lines
6.4 KiB
Go
235 lines
6.4 KiB
Go
|
|
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
|
|||
|
|
}
|