2026-08-03 05:52:02 +00:00
|
|
|
|
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) {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
return s.GetTodayFiltered(ctx, ownerUID, domain.OpportunityListFilter{})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productFilter domain.OpportunityListFilter) (*TodayResult, error) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
BrandID: productFilter.BrandID,
|
|
|
|
|
|
ProductID: productFilter.ProductID,
|
|
|
|
|
|
FitBand: productFilter.FitBand,
|
2026-08-03 05:52:02 +00:00
|
|
|
|
CreatedFrom: start,
|
|
|
|
|
|
CreatedTo: end,
|
|
|
|
|
|
Page: 1,
|
|
|
|
|
|
PageSize: 100,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
productFiltered := productFilter.BrandID != "" || productFilter.ProductID != "" || productFilter.FitBand != ""
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
2026-08-19 02:01:15 +00:00
|
|
|
|
visible := make([]*domain.Opportunity, 0, len(list))
|
2026-08-03 05:52:02 +00:00
|
|
|
|
for _, o := range list {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if len(o.ProductMatches) > 0 && !todayHasEligibleProduct(o, productFilter) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if productFiltered && len(o.ProductMatches) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-08-19 02:01:15 +00:00
|
|
|
|
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 {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
card := TodayOpportunity{Opportunity: o}
|
2026-08-19 02:01:15 +00:00
|
|
|
|
for _, r := range repliesByOpportunity[o.ID] {
|
|
|
|
|
|
if r.Variant == domain.ReplyPublicComment {
|
|
|
|
|
|
card.DefaultReply = r
|
|
|
|
|
|
break
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if productFiltered {
|
|
|
|
|
|
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
|
|
|
|
|
|
} else {
|
2026-08-19 07:37:44 +00:00
|
|
|
|
out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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", "這輪有巡但沒有符合的商機。可放寬關鍵字或檢查排除詞是否太嚴。"
|
|
|
|
|
|
}
|