thread-master/apps/backend/internal/module/radar/usecase/opportunity_ops.go

163 lines
5.7 KiB
Go

package usecase
import (
"context"
"fmt"
"strings"
"apps/backend/internal/module/radar/domain"
)
func (s *Service) GetOpportunity(ctx context.Context, ownerUID int64, id string) (*domain.Opportunity, error) {
o, err := s.Repo.GetOpportunity(ctx, id)
if err != nil {
return nil, err
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
return o, nil
}
func (s *Service) ListOpportunities(ctx context.Context, ownerUID int64, f domain.OpportunityListFilter) ([]*domain.Opportunity, int64, error) {
if ownerUID <= 0 {
return nil, 0, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
if f.FitBand != "" && !domain.IsProductFitBand(f.FitBand) {
return nil, 0, fmt.Errorf("%w: unknown product fit band %q", domain.ErrValidation, f.FitBand)
}
if f.MatchState != "" && !strings.Contains(" eligible weak excluded generic stale ", " "+f.MatchState+" ") {
return nil, 0, fmt.Errorf("%w: unknown product match state %q", domain.ErrValidation, f.MatchState)
}
if f.Sort != "" && f.Sort != "posted" && f.Sort != "score" && f.Sort != "recommended" && f.Sort != "newest" && f.Sort != "oldest" && f.Sort != "product_fit" && f.Sort != "demand_intent" && f.Sort != "priority" && f.Sort != "priority_score" {
return nil, 0, fmt.Errorf("%w: unknown opportunity sort %q", domain.ErrValidation, f.Sort)
}
if f.ReviewState != "" && !domain.IsReviewState(f.ReviewState) {
return nil, 0, fmt.Errorf("%w: unknown review state %q", domain.ErrValidation, f.ReviewState)
}
if f.TimeScope != "" && f.TimeScope != "today" && f.TimeScope != "7d" && f.TimeScope != "all" {
return nil, 0, fmt.Errorf("%w: unknown time scope %q", domain.ErrValidation, f.TimeScope)
}
if f.PriorityBand != "" && f.PriorityBand != "high" && f.PriorityBand != "review" && f.PriorityBand != "low" {
return nil, 0, fmt.Errorf("%w: unknown priority band %q", domain.ErrValidation, f.PriorityBand)
}
return s.Repo.ListOpportunities(ctx, ownerUID, f)
}
func (s *Service) UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, id string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) {
if ownerUID <= 0 || strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: owner and opportunity are required", domain.ErrValidation)
}
if patch.State == domain.ReviewRemoved && patch.RemovalReason == "" {
return nil, fmt.Errorf("%w: removal reason required", domain.ErrValidation)
}
return s.Repo.UpdateOpportunityReviewState(ctx, ownerUID, id, patch)
}
func (s *Service) ListSweeps(ctx context.Context, ownerUID int64, f domain.SweepListFilter) ([]*domain.RadarSweep, int64, error) {
return s.Repo.ListSweeps(ctx, ownerUID, f)
}
// TriggerSweep enqueues a radar_sweep job for a watch (manual). Same path as daily schedule.
func (s *Service) TriggerSweep(ctx context.Context, ownerUID int64, watchID string) (jobID string, err error) {
if s.SweepJobs == nil {
return "", fmt.Errorf("%w: sweep scheduler not configured", domain.ErrNotReady)
}
w, err := s.Repo.GetWatch(ctx, watchID)
if err != nil {
return "", err
}
if w.OwnerUID != ownerUID {
return "", domain.ErrForbidden
}
if w.Status != domain.WatchActive {
return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status)
}
// Manual trigger is due now.
return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
}
func (s *Service) AcceptOpportunity(ctx context.Context, ownerUID int64, id string) (*domain.Opportunity, string, error) {
o, err := s.GetOpportunity(ctx, ownerUID, id)
if err != nil {
return nil, "", err
}
if err := o.Transition(domain.OppAccepted); err != nil {
return nil, "", err
}
contactID := o.ContactID
if s.CRM != nil {
cid, berr := s.CRM.BindOpportunity(ctx, ownerUID, o)
if berr != nil {
return nil, "", berr
}
contactID = cid
o.ContactID = cid
}
if err := s.Repo.UpdateOpportunityStatus(ctx, id, domain.OppAccepted); err != nil {
return nil, "", err
}
if contactID != "" {
if err := s.setContactID(ctx, id, contactID); err != nil {
return nil, "", err
}
}
// 加入 CRM 是收件匣的正向完成動作。業務狀態 accepted 與工作狀態
// completed 必須一起成立,否則加入名單後同一張卡仍會留在待處理。
o, err = s.Repo.UpdateOpportunityReviewState(ctx, ownerUID, id, domain.ReviewStatePatch{State: domain.ReviewCompleted})
if err != nil {
return nil, contactID, err
}
return o, contactID, nil
}
func (s *Service) setContactID(ctx context.Context, id, contactID string) error {
type patcher interface {
SetOpportunityContact(ctx context.Context, id, contactID string) error
}
if p, ok := s.Repo.(patcher); ok {
return p.SetOpportunityContact(ctx, id, contactID)
}
return nil
}
func (s *Service) DismissOpportunity(ctx context.Context, ownerUID int64, id, reason string) (*domain.Opportunity, error) {
o, err := s.GetOpportunity(ctx, ownerUID, id)
if err != nil {
return nil, err
}
if err := o.Transition(domain.OppDismissed); err != nil {
return nil, err
}
if err := s.Repo.UpdateOpportunityStatus(ctx, id, domain.OppDismissed); err != nil {
return nil, err
}
_ = reason
return s.Repo.GetOpportunity(ctx, id)
}
func (s *Service) OverrideOpportunity(ctx context.Context, ownerUID int64, id, band, status string) (*domain.Opportunity, error) {
o, err := s.GetOpportunity(ctx, ownerUID, id)
if err != nil {
return nil, err
}
ov := &domain.OpportunityOverride{
FromBand: o.IntentBand,
ToBand: band,
FromStatus: o.Status,
ToStatus: status,
ActorUID: ownerUID,
At: domain.NowNano(),
}
if status == "" {
status = o.Status
}
if band == "" {
band = o.IntentBand
}
if err := s.Repo.SetOpportunityOverride(ctx, id, ov, band, status); err != nil {
return nil, err
}
return s.Repo.GetOpportunity(ctx, id)
}