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

184 lines
5.3 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"
"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今日名單含可操作與已處理排除 rejectedjudgingT545
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", "這輪有巡但沒有符合的商機。可放寬關鍵字或檢查排除詞是否太嚴。"
}