package usecase import ( "context" "fmt" "time" "apps/backend/internal/module/crm/domain" radarDomain "apps/backend/internal/module/radar/domain" ) // GrowthOutcomes writes conversion into existing growth_outcomes (source_type=radar_opportunity). type GrowthOutcomes interface { RecordConversion(ctx context.Context, ownerUID int64, contactID string, amount float64, currency, note string) (outcomeID string, err error) AmendConversion(ctx context.Context, ownerUID int64, outcomeID string, amount float64, currency, note string) error } type Service struct { Repo domain.Repository Growth GrowthOutcomes // RadarOpps optional for contact detail briefs RadarOpps interface { GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error) } Notifier FollowUpNotifier } type FollowUpNotifier interface { NotifyFollowUp(ctx context.Context, ownerUID int64, contactID, followUpID string) error } func New(repo domain.Repository) *Service { return &Service{Repo: repo} } // BindOpportunity implements radar.ContactBinder. func (s *Service) BindOpportunity(ctx context.Context, ownerUID int64, opp *radarDomain.Opportunity) (string, error) { if opp == nil { return "", domain.ErrValidation } c := &domain.Contact{ OwnerUID: ownerUID, SourcePlatform: domain.PlatformThreads, AuthorHandle: opp.AuthorHandle, Stage: domain.StageNewFound, OpportunityIDs: []string{opp.ID}, TopIntentBand: opp.IntentBand, TopIntentScore: opp.IntentScore, LastTouchAt: domain.NowNano(), FollowUpDays: domain.DefaultFollowUpDays, } got, err := s.Repo.UpsertContactByIdentity(ctx, c) if err != nil { return "", err } // touch _ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: got.ID, Type: domain.TouchStage, ToStage: got.Stage, Body: "從商機加入名單", ActorUID: ownerUID, CreatedAt: domain.NowNano(), }) return got.ID, nil } func (s *Service) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, map[string]int, error) { list, total, err := s.Repo.ListContacts(ctx, ownerUID, f) if err != nil { return nil, 0, nil, err } counts, err := s.Repo.CountByStage(ctx, ownerUID) if err != nil { return nil, 0, nil, err } return list, total, counts, nil } func (s *Service) GetContact(ctx context.Context, ownerUID, page, pageSize int64, id string) (*domain.Contact, []*domain.ContactTouch, int64, error) { c, err := s.GetContactOnly(ctx, ownerUID, id) if err != nil { return nil, nil, 0, err } touches, total, err := s.Repo.ListTouches(ctx, ownerUID, id, int(page), int(pageSize)) return c, touches, total, err } // GetContactOnly returns the contact without timeline (owner-checked). func (s *Service) GetContactOnly(ctx context.Context, ownerUID int64, id string) (*domain.Contact, error) { c, err := s.Repo.GetContact(ctx, id) if err != nil { return nil, err } if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return c, nil } // OpportunityBriefs resolves linked opportunities for contact detail. func (s *Service) OpportunityBriefs(ctx context.Context, ownerUID int64, ids []string) []map[string]any { out := make([]map[string]any, 0, len(ids)) if s.RadarOpps == nil { return out } for _, id := range ids { o, err := s.RadarOpps.GetOpportunity(ctx, id) if err != nil || o == nil || o.OwnerUID != ownerUID { continue } out = append(out, map[string]any{ "id": o.ID, "permalink": o.Permalink, "text": o.Text, "intent_score": o.IntentScore, "intent_band": o.IntentBand, "created_at": o.CreatedAt, }) } return out } func (s *Service) UpdateStage(ctx context.Context, ownerUID int64, id, stage, note string) (*domain.Contact, error) { if !domain.IsStage(stage) { return nil, fmt.Errorf("%w: unknown stage %q", domain.ErrValidation, stage) } c, err := s.Repo.GetContact(ctx, id) if err != nil { return nil, err } if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } from := c.Stage c.Stage = stage c.LastTouchAt = domain.NowNano() c.UpdatedAt = c.LastTouchAt if err := s.Repo.SaveContact(ctx, c); err != nil { return nil, err } _ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: id, Type: domain.TouchStage, FromStage: from, ToStage: stage, Body: note, ActorUID: ownerUID, CreatedAt: domain.NowNano(), }) return c, nil } func (s *Service) SetFollowUp(ctx context.Context, ownerUID int64, id string, needs bool, days int) (*domain.Contact, error) { c, err := s.Repo.GetContact(ctx, id) if err != nil { return nil, err } if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } c.NeedsFollowUp = needs if days > 0 { c.FollowUpDays = days } c.UpdatedAt = domain.NowNano() if err := s.Repo.SaveContact(ctx, c); err != nil { return nil, err } if needs { due := domain.NowNano() + int64(c.FollowUpDays)*int64(24*time.Hour) fu := &domain.FollowUp{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: id, DueAt: due, Status: domain.FollowUpScheduled, CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), } _ = s.Repo.SaveFollowUp(ctx, fu) } return c, nil } func (s *Service) AddNote(ctx context.Context, ownerUID int64, id, body string) (*domain.ContactTouch, error) { c, err := s.Repo.GetContact(ctx, id) if err != nil { return nil, err } if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } t := &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: id, Type: domain.TouchNote, Body: body, ActorUID: ownerUID, CreatedAt: domain.NowNano(), } if err := s.Repo.InsertTouch(ctx, t); err != nil { return nil, err } c.LastTouchAt = t.CreatedAt c.UpdatedAt = t.CreatedAt _ = s.Repo.SaveContact(ctx, c) return t, nil } func (s *Service) Merge(ctx context.Context, ownerUID int64, targetID, sourceID string) (*domain.Contact, error) { if targetID == sourceID { return nil, fmt.Errorf("%w: cannot merge contact into itself", domain.ErrValidation) } target, err := s.Repo.GetContact(ctx, targetID) if err != nil { return nil, err } source, err := s.Repo.GetContact(ctx, sourceID) if err != nil { return nil, err } if target.OwnerUID != ownerUID || source.OwnerUID != ownerUID { return nil, domain.ErrForbidden } seen := map[string]bool{} for _, id := range target.OpportunityIDs { seen[id] = true } for _, id := range source.OpportunityIDs { if !seen[id] { target.OpportunityIDs = append(target.OpportunityIDs, id) } } target.MergedFrom = append(target.MergedFrom, sourceID) if source.TopIntentScore > target.TopIntentScore { target.TopIntentScore = source.TopIntentScore target.TopIntentBand = source.TopIntentBand } target.UpdatedAt = domain.NowNano() if err := s.Repo.SaveContact(ctx, target); err != nil { return nil, err } // mark source lost / archived-ish source.Stage = domain.StageLost source.UpdatedAt = domain.NowNano() _ = s.Repo.SaveContact(ctx, source) return target, nil } func (s *Service) Unmerge(ctx context.Context, ownerUID int64, targetID, mergedID string) (*domain.Contact, error) { target, err := s.Repo.GetContact(ctx, targetID) if err != nil { return nil, err } if target.OwnerUID != ownerUID { return nil, domain.ErrForbidden } out := make([]string, 0, len(target.MergedFrom)) for _, id := range target.MergedFrom { if id != mergedID { out = append(out, id) } } target.MergedFrom = out target.UpdatedAt = domain.NowNano() if err := s.Repo.SaveContact(ctx, target); err != nil { return nil, err } return target, nil } func (s *Service) ReportConversion(ctx context.Context, ownerUID int64, contactID string, amount float64, currency, note string) (*domain.Contact, string, error) { c, err := s.Repo.GetContact(ctx, contactID) if err != nil { return nil, "", err } if c.OwnerUID != ownerUID { return nil, "", domain.ErrForbidden } if s.Growth == nil { return nil, "", fmt.Errorf("%w: growth outcomes not configured", domain.ErrNotReady) } now := domain.NowNano() outcomeID, err := s.Growth.RecordConversion(ctx, ownerUID, contactID, amount, currency, note) if err != nil { return nil, "", err } c.Stage = domain.StageWon c.OutcomeID = outcomeID c.LastTouchAt = now c.UpdatedAt = now if err := s.Repo.SaveContact(ctx, c); err != nil { return nil, "", err } _ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: contactID, Type: domain.TouchConversion, ToStage: domain.StageWon, Body: fmt.Sprintf("成交回報 %.0f %s %s", amount, currency, note), ActorUID: ownerUID, CreatedAt: now, }) return c, outcomeID, nil } func (s *Service) UpdateConversion(ctx context.Context, ownerUID int64, contactID string, amount float64, currency, note string) (*domain.Contact, string, error) { c, err := s.Repo.GetContact(ctx, contactID) if err != nil { return nil, "", err } if c.OwnerUID != ownerUID { return nil, "", domain.ErrForbidden } if s.Growth == nil || c.OutcomeID == "" { return s.ReportConversion(ctx, ownerUID, contactID, amount, currency, note) } if err := s.Growth.AmendConversion(ctx, ownerUID, c.OutcomeID, amount, currency, note); err != nil { return s.ReportConversion(ctx, ownerUID, contactID, amount, currency, note) } _ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: contactID, Type: domain.TouchConversion, Body: "修改成交紀錄", ActorUID: ownerUID, CreatedAt: domain.NowNano(), }) return c, c.OutcomeID, nil } func (s *Service) DeleteConversion(ctx context.Context, ownerUID int64, contactID string) error { c, err := s.Repo.GetContact(ctx, contactID) if err != nil { return err } if c.OwnerUID != ownerUID { return domain.ErrForbidden } // audit touch; leave outcome row but clear link c.OutcomeID = "" c.UpdatedAt = domain.NowNano() _ = s.Repo.SaveContact(ctx, c) return s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: contactID, Type: domain.TouchConversion, Body: "刪除成交標記", ActorUID: ownerUID, CreatedAt: domain.NowNano(), }) } func (s *Service) ListFollowUps(ctx context.Context, ownerUID int64, f domain.FollowUpListFilter) ([]*domain.FollowUp, int64, error) { return s.Repo.ListFollowUps(ctx, ownerUID, f) } func (s *Service) DoneFollowUp(ctx context.Context, ownerUID int64, id string) (*domain.FollowUp, error) { f, err := s.Repo.GetFollowUp(ctx, id) if err != nil { return nil, err } if f.OwnerUID != ownerUID { return nil, domain.ErrForbidden } f.Status = domain.FollowUpDone f.UpdatedAt = domain.NowNano() if err := s.Repo.SaveFollowUp(ctx, f); err != nil { return nil, err } return f, nil } func (s *Service) SnoozeFollowUp(ctx context.Context, ownerUID int64, id string, days int) (*domain.FollowUp, error) { if days <= 0 { days = 3 } f, err := s.Repo.GetFollowUp(ctx, id) if err != nil { return nil, err } if f.OwnerUID != ownerUID { return nil, domain.ErrForbidden } f.Status = domain.FollowUpSnoozed f.DueAt = domain.NowNano() + int64(days)*int64(24*time.Hour) f.UpdatedAt = domain.NowNano() if err := s.Repo.SaveFollowUp(ctx, f); err != nil { return nil, err } return f, nil } // GenerateFollowUpMessage drafts a short follow-up message (T588); never auto-sends. func (s *Service) GenerateFollowUpMessage(ctx context.Context, ownerUID int64, followUpID string) (string, error) { f, err := s.Repo.GetFollowUp(ctx, followUpID) if err != nil { return "", err } if f.OwnerUID != ownerUID { return "", domain.ErrForbidden } c, err := s.GetContactOnly(ctx, ownerUID, f.ContactID) if err != nil { return "", err } handle := c.AuthorHandle if handle == "" { handle = "你好" } else { handle = "@" + handle } // 繁中台灣語氣、不硬銷;使用者可再改。 return fmt.Sprintf( "%s 嗨,上次聊到你的需求,想再跟你確認一下目前進度如何?若還在比較方案,我可以幫你整理重點給你參考。", handle, ), nil } // ScanFollowUps marks due items notified (M5 job). func (s *Service) ScanFollowUps(ctx context.Context, now int64) (int, error) { if now <= 0 { now = domain.NowNano() } due, err := s.Repo.ListDueFollowUps(ctx, now, 100) if err != nil { return 0, err } n := 0 for _, f := range due { f.Status = domain.FollowUpNotified f.NotifiedCount++ f.UpdatedAt = now if f.NotifiedCount >= 2 { f.Status = domain.FollowUpEscalated } if err := s.Repo.SaveFollowUp(ctx, f); err != nil { continue } if s.Notifier != nil { _ = s.Notifier.NotifyFollowUp(ctx, f.OwnerUID, f.ContactID, f.ID) } n++ } return n, nil } // Stats returns three-dimension CRM stats for the range. func (s *Service) Stats(ctx context.Context, ownerUID int64, from, to int64) (map[string]any, error) { counts, err := s.Repo.CountByStage(ctx, ownerUID) if err != nil { return nil, err } list, total, err := s.Repo.ListContacts(ctx, ownerUID, domain.ContactListFilter{Page: 1, PageSize: 500}) if err != nil { return nil, err } won := counts[domain.StageWon] _ = from _ = to return map[string]any{ "total_contacts": total, "by_stage": counts, "won": won, "follow_up": counts["needs_follow_up"], "sample": len(list), }, nil }