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
|
||
}
|