442 lines
13 KiB
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
|
||
|
|
}
|