package usecase import ( "context" "fmt" "apps/backend/internal/module/radar/domain" ) // TodayStats is the high/mid/low count block for the today page. type TodayStats struct { Total int High int Mid int Low int } // TodayOpportunity is one card on the today page (opp + optional default reply). type TodayOpportunity struct { Opportunity *domain.Opportunity DefaultReply *domain.ReplyVariant } // TodayResult powers GET /radar/today. type TodayResult struct { Stats TodayStats High []TodayOpportunity Mid []TodayOpportunity Low []TodayOpportunity TruncatedCount int LastSweptAt int64 EmptyReason string EmptyHint string } // todayListStatuses:今日名單含可操作與已處理,排除 rejected/judging(T545)。 var todayListStatuses = []string{domain.OppQualified, domain.OppAccepted, domain.OppDismissed} func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, error) { return s.GetTodayFiltered(ctx, ownerUID, domain.OpportunityListFilter{}) } func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productFilter domain.OpportunityListFilter) (*TodayResult, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } start, end := domain.UTCDayBounds(domain.NowNano()) watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50}) if err != nil { return nil, err } active, err := s.Repo.ListActiveWatches(ctx, ownerUID) if err != nil { return nil, err } var lastSwept int64 for _, w := range watches { if w.LastSweptAt > lastSwept { lastSwept = w.LastSweptAt } } list, _, err := s.Repo.ListOpportunities(ctx, ownerUID, domain.OpportunityListFilter{ Statuses: todayListStatuses, BrandID: productFilter.BrandID, ProductID: productFilter.ProductID, FitBand: productFilter.FitBand, CreatedFrom: start, CreatedTo: end, Page: 1, PageSize: 100, }) if err != nil { return nil, err } productFiltered := productFilter.BrandID != "" || productFilter.ProductID != "" || productFilter.FitBand != "" visible := make([]*domain.Opportunity, 0, len(list)) for _, o := range list { if len(o.ProductMatches) > 0 && !todayHasEligibleProduct(o, productFilter) { continue } if productFiltered && len(o.ProductMatches) == 0 { continue } visible = append(visible, o) } visibleIDs := make([]string, 0, len(visible)) for _, o := range visible { visibleIDs = append(visibleIDs, o.ID) } // 一次取回整頁的回覆;預設回覆只是輔助資訊,查不到不擋今日名單。 repliesByOpportunity, rerr := s.Repo.ListRepliesForOpportunities(ctx, ownerUID, visibleIDs) if rerr != nil { repliesByOpportunity = nil } high, mid, low := []TodayOpportunity{}, []TodayOpportunity{}, []TodayOpportunity{} for _, o := range visible { card := TodayOpportunity{Opportunity: o} for _, r := range repliesByOpportunity[o.ID] { if r.Variant == domain.ReplyPublicComment { card.DefaultReply = r break } } switch o.IntentBand { case domain.BandHigh: high = append(high, card) case domain.BandMid: mid = append(mid, card) default: low = append(low, card) } } total := len(high) + len(mid) + len(low) // truncated from today's sweeps sweeps, _, _ := s.Repo.ListSweeps(ctx, ownerUID, domain.SweepListFilter{Page: 1, PageSize: 20}) trunc := 0 var latestFail string for _, sw := range sweeps { if sw.StartedAt >= start && sw.StartedAt < end { trunc += sw.TruncatedCount if sw.FailedReason != "" { latestFail = sw.FailedReason } } } out := &TodayResult{ Stats: TodayStats{Total: total, High: len(high), Mid: len(mid), Low: len(low)}, High: high, Mid: mid, Low: low, TruncatedCount: trunc, LastSweptAt: lastSwept, } if total == 0 { if productFiltered { out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。" } else { out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start) } } return out, nil } func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilter) bool { for _, match := range o.ProductMatches { if match == nil || !match.Eligible || match.Excluded { continue } if f.BrandID != "" && match.BrandID != f.BrandID { continue } if f.ProductID != "" && match.ProductID != f.ProductID { continue } if f.FitBand != "" && match.ProductFitBand != f.FitBand { continue } return true } return false } func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { if watchCount == 0 { return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。" } if activeCount == 0 { return "all_watches_paused", "目前沒有啟用中的訂閱。恢復一組訂閱後才會繼續巡。" } if fail != "" { return "sweep_failed", fail } if lastSwept < dayStart { return "not_swept_yet", "今日巡檢還沒跑完(每日 UTC 22:00 開始)。也可在訂閱頁手動觸發。" } return "no_hit", "這輪有巡但沒有符合的商機。可放寬關鍵字或檢查排除詞是否太嚴。" }