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

464 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"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 and conversion attribution
RadarOpps interface {
GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error)
ListOpportunities(ctx context.Context, ownerUID int64, f radarDomain.OpportunityListFilter) ([]*radarDomain.Opportunity, int64, 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) {
if ownerUID <= 0 {
return nil, 0, nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
f.Query = strings.TrimSpace(f.Query)
if utf8.RuneCountInString(f.Query) > 80 {
return nil, 0, nil, fmt.Errorf("%w: query is too long", domain.ErrValidation)
}
if f.Stage != "" && !domain.IsStage(f.Stage) {
return nil, 0, nil, fmt.Errorf("%w: unknown stage %q", domain.ErrValidation, f.Stage)
}
if f.PageSize > 50 {
f.PageSize = 50
}
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
}
if c.RemovedAt > 0 {
return nil, domain.ErrNotFound
}
return c, nil
}
// RemoveContact removes a contact from the working list and closes active
// follow-ups. Opportunity, touch, and conversion history keep their contact ID.
func (s *Service) RemoveContact(ctx context.Context, ownerUID int64, id string) error {
if _, err := s.GetContactOnly(ctx, ownerUID, id); err != nil {
return err
}
return s.Repo.RemoveContact(ctx, ownerUID, id, domain.NowNano())
}
// 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
}
// spec FU-04延後只是把到期日後移狀態回到可再次排程的 scheduled。
f.Status = domain.FollowUpScheduled
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.NotifiedCount++
f.UpdatedAt = now
if f.NotifiedCount >= domain.MaxFollowUpNotifications {
// 達上限只建議轉未成交不再排下一次通知spec FU-03
f.Status = domain.FollowUpEscalated
} else {
// 到期日必須往後推,否則下一個 tick 會立刻重複通知同一筆
f.Status = domain.FollowUpNotified
f.DueAt = now + int64(s.followUpDays(ctx, f.ContactID))*int64(24*time.Hour)
}
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
}
// followUpDays resolves the owner's configured interval, falling back to the
// default when the contact is gone or never had one set.
func (s *Service) followUpDays(ctx context.Context, contactID string) int {
c, err := s.Repo.GetContact(ctx, contactID)
if err != nil || c == nil || c.FollowUpDays <= 0 {
return domain.DefaultFollowUpDays
}
return c.FollowUpDays
}