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 and must not reuse a finished daily slot. if manual, ok := s.SweepJobs.(ManualSweepJobScheduler); ok { return manual.ScheduleManualRadarSweep(ctx, ownerUID, watchID, domain.NowNano()) } 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) }