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

442 lines
13 KiB
Go

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
}