package domain import ( "fmt" "strings" "time" ) // Opportunity status — spec §3.2. const ( OppJudging = "judging" OppQualified = "qualified" OppRejected = "rejected" OppAccepted = "accepted" OppDismissed = "dismissed" ) // ReviewState is the inbox workflow and deliberately remains separate from // Opportunity status (accepted still belongs to CRM). const ( ReviewPending = "pending" ReviewCompleted = "completed" ReviewRemoved = "removed" ) const ( RemovalPainMismatch = "pain_mismatch" RemovalProviderAd = "provider_or_ad" RemovalStale = "stale" RemovalAlreadySolved = "already_solved" RemovalDuplicate = "duplicate" RemovalOther = "other" RemovalLegacy = "legacy_unknown" ) // Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here. const ( BandHigh = "high" BandMid = "mid" BandLow = "low" BandHighMinScore = 80 BandMidMinScore = 50 ) // Region match is three-state; unknown is not mismatch (OP-04). const ( RegionMatch = "match" RegionMismatch = "mismatch" RegionUnknown = "unknown" ) // Opportunity sources. const ( OppSourceThreads = "threads" // OppSourceManualImport:使用者貼 Threads/Facebook 貼文網址或 CSV 批次匯入(spec §4.11 P1)。 OppSourceManualImport = "manual_import" OppSourceScoutPromote = "scout_promote" ) // Five judge dimensions — reasons[] must include every one (OP-06). const ( DimAuthenticity = "authenticity" DimIntent = "intent" DimRegion = "region" DimFreshness = "freshness" DimFit = "fit" ) // RequiredReasonDimensions is the fixed set that must all be present to persist. var RequiredReasonDimensions = []string{ DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit, } // OpportunityReason is one scored dimension of the five-question judge. type OpportunityReason struct { Dimension string `bson:"dimension" json:"dimension"` Score int `bson:"score" json:"score"` Reason string `bson:"reason" json:"reason"` } // OpportunityOverride records a human band/status correction for later calibration. type OpportunityOverride struct { FromBand string `bson:"from_band,omitempty" json:"from_band,omitempty"` ToBand string `bson:"to_band,omitempty" json:"to_band,omitempty"` FromStatus string `bson:"from_status,omitempty" json:"from_status,omitempty"` ToStatus string `bson:"to_status,omitempty" json:"to_status,omitempty"` ActorUID int64 `bson:"actor_uid" json:"actor_uid"` At int64 `bson:"at" json:"at"` } type ProductPrimaryOverride struct { ActorUID int64 `bson:"actor_uid" json:"actor_uid"` Reason string `bson:"reason" json:"reason"` At int64 `bson:"at" json:"at"` } /* Opportunity 是經五問判定後的商機。 同一 owner 下 external_id 唯一:跨 watch 命中同一貼文只留一筆,新觸發 term 併入 matched_terms,不重跑判定(SW-06)。 */ type Opportunity struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"` Source string `bson:"source" json:"source"` SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"` ExternalID string `bson:"external_id" json:"external_id"` Permalink string `bson:"permalink" json:"permalink"` AuthorHandle string `bson:"author_handle" json:"author_handle"` Text string `bson:"text" json:"text"` PostedAt int64 `bson:"posted_at" json:"posted_at"` Status string `bson:"status" json:"status"` IntentScore int `bson:"intent_score" json:"intent_score"` IntentBand string `bson:"intent_band" json:"intent_band"` Reasons []OpportunityReason `bson:"reasons" json:"reasons"` RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"` RegionMatch string `bson:"region_match" json:"region_match"` FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"` MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"` MatchedTerms []string `bson:"matched_terms" json:"matched_terms"` RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"` Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"` ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"` PrimaryBrandID string `bson:"primary_brand_id,omitempty" json:"primary_brand_id,omitempty"` PrimaryProductID string `bson:"primary_product_id,omitempty" json:"primary_product_id,omitempty"` PrimaryBrandName string `bson:"primary_brand_name,omitempty" json:"primary_brand_name,omitempty"` PrimaryProductLabel string `bson:"primary_product_label,omitempty" json:"primary_product_label,omitempty"` PrimaryProductFitScore int `bson:"primary_product_fit_score,omitempty" json:"primary_product_fit_score,omitempty"` PrimaryProductFitBand string `bson:"primary_product_fit_band,omitempty" json:"primary_product_fit_band,omitempty"` PrimaryProductOverridden bool `bson:"primary_product_overridden,omitempty" json:"primary_product_overridden,omitempty"` PrimaryProductOverride *ProductPrimaryOverride `bson:"primary_product_override,omitempty" json:"primary_product_override,omitempty"` ProductMatches []*ProductMatch `bson:"product_matches,omitempty" json:"product_matches,omitempty"` ReviewState string `bson:"review_state,omitempty" json:"review_state,omitempty"` PreviousReviewState string `bson:"previous_review_state,omitempty" json:"previous_review_state,omitempty"` RemovalReason string `bson:"removal_reason,omitempty" json:"removal_reason,omitempty"` RemovalNote string `bson:"removal_note,omitempty" json:"removal_note,omitempty"` RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"` RemovedBy int64 `bson:"removed_by,omitempty" json:"removed_by,omitempty"` LastMatchedAt int64 `bson:"last_matched_at,omitempty" json:"last_matched_at,omitempty"` PriorityScore int `bson:"priority_score,omitempty" json:"priority_score,omitempty"` PriorityBand string `bson:"priority_band,omitempty" json:"priority_band,omitempty"` PainFitScore int `bson:"pain_fit_score,omitempty" json:"pain_fit_score,omitempty"` DemandIntentScore int `bson:"demand_intent_score,omitempty" json:"demand_intent_score,omitempty"` EvidenceQualityScore int `bson:"evidence_quality_score,omitempty" json:"evidence_quality_score,omitempty"` FreshnessScore int `bson:"freshness_score,omitempty" json:"freshness_score,omitempty"` DemandEvidence []string `bson:"demand_evidence,omitempty" json:"demand_evidence,omitempty"` DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"` DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } // OpportunityListFilter 支援 band/status/watch/日期區間。 // Status 與 Statuses 擇一:Statuses 非空時用 $in;否則 Status 做單值比對。 type OpportunityListFilter struct { Band string Status string Statuses []string // 多狀態;$in(今日頁:qualified/accepted/dismissed) WatchID string CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound CreatedTo int64 // exclusive, unix ns; 0 = no upper bound PostedFrom int64 PostedTo int64 Page int PageSize int BrandID string ProductID string FitBand string MatchState string Sort string ReviewState string TimeScope string PriorityBand string } type ReviewStatePatch struct { State string RemovalReason string RemovalNote string DuplicateOpportunityID string RemovedBy int64 } func IsReviewState(s string) bool { return s == ReviewPending || s == ReviewCompleted || s == ReviewRemoved } func IsRemovalReason(s string) bool { switch s { case RemovalPainMismatch, RemovalProviderAd, RemovalStale, RemovalAlreadySolved, RemovalDuplicate, RemovalOther, RemovalLegacy: return true default: return false } } // BandFromScore maps intent_score → band with locked 80/50 thresholds. func BandFromScore(score int) string { if score >= BandHighMinScore { return BandHigh } if score >= BandMidMinScore { return BandMid } return BandLow } func IsOppStatus(s string) bool { switch s { case OppJudging, OppQualified, OppRejected, OppAccepted, OppDismissed: return true } return false } func IsIntentBand(s string) bool { switch s { case BandHigh, BandMid, BandLow: return true } return false } func IsRegionMatch(s string) bool { switch s { case RegionMatch, RegionMismatch, RegionUnknown: return true } return false } func IsOppSource(s string) bool { switch s { case OppSourceThreads, OppSourceManualImport, OppSourceScoutPromote: return true } return false } /* CanTransitionOpportunity 實作 spec §3.2。 accepted/dismissed 為使用者終態;rejected → qualified 僅經覆寫。 judging 之後不可回到 judging。 */ func CanTransitionOpportunity(from, to string) bool { if from == to { return true } switch from { case OppJudging: return to == OppQualified || to == OppRejected case OppQualified: return to == OppAccepted || to == OppDismissed case OppRejected: return to == OppQualified default: return false } } func (o *Opportunity) Transition(to string) error { if !IsOppStatus(to) { return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, to) } if !CanTransitionOpportunity(o.Status, to) { return fmt.Errorf("%w: cannot change opportunity from %s to %s", ErrValidation, o.Status, to) } o.Status = to o.UpdatedAt = NowNano() return nil } // ApplyBandFromScore sets IntentBand from IntentScore (locked thresholds). func (o *Opportunity) ApplyBandFromScore() { o.IntentBand = BandFromScore(o.IntentScore) } /* ValidateReasons 要求五維度齊全、每條有非空白人話理由(OP-06)。 缺任一維度 → 明確錯誤,repository 不得寫入。 */ func ValidateReasons(reasons []OpportunityReason) error { if len(reasons) == 0 { return fmt.Errorf("%w: reasons required (need all five dimensions)", ErrValidation) } seen := map[string]bool{} for _, r := range reasons { dim := strings.TrimSpace(r.Dimension) if dim == "" { return fmt.Errorf("%w: reasons entry missing dimension", ErrValidation) } if !isReasonDimension(dim) { return fmt.Errorf("%w: unknown reason dimension %q", ErrValidation, dim) } if seen[dim] { return fmt.Errorf("%w: duplicate reason dimension %q", ErrValidation, dim) } if strings.TrimSpace(r.Reason) == "" { return fmt.Errorf("%w: reasons[%s] needs a human-readable reason", ErrValidation, dim) } seen[dim] = true } for _, dim := range RequiredReasonDimensions { if !seen[dim] { return fmt.Errorf("%w: reasons missing dimension %q (need all five)", ErrValidation, dim) } } return nil } func isReasonDimension(s string) bool { switch s { case DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit: return true } return false } // NormalizeMatchedTerms lower-cases, trims, and de-duplicates terms. func NormalizeMatchedTerms(in []string) []string { out := make([]string, 0, len(in)) seen := map[string]bool{} for _, raw := range in { t := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " "))) t = strings.Join(strings.Fields(t), " ") if t == "" || seen[t] { continue } seen[t] = true out = append(out, t) } return out } // MergeMatchedTerms returns a∪b with stable order (a first, then new from b). func MergeMatchedTerms(a, b []string) []string { return NormalizeMatchedTerms(append(append([]string{}, a...), b...)) } /* ValidateForWrite 在首次寫入/完整更新前檢查契約欄位。 Upsert 命中既有列時只併 term,不走這條(既有資料已通過判定)。 */ func (o *Opportunity) ValidateForWrite() error { if o.OwnerUID <= 0 { return fmt.Errorf("%w: owner_uid required", ErrValidation) } if strings.TrimSpace(o.ExternalID) == "" { return fmt.Errorf("%w: external_id required", ErrValidation) } if o.Source == "" { o.Source = OppSourceThreads } if !IsOppSource(o.Source) { return fmt.Errorf("%w: unknown opportunity source %q", ErrValidation, o.Source) } if o.Status == "" { o.Status = OppJudging } if !IsOppStatus(o.Status) { return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, o.Status) } // judging 尚未出分時允許缺 reasons;一旦進入 qualified/rejected 必須五條齊全。 if o.Status != OppJudging { if err := ValidateReasons(o.Reasons); err != nil { return err } } else if len(o.Reasons) > 0 { // 若呼叫端已帶 reasons,仍驗一次,避免半成品入庫。 if err := ValidateReasons(o.Reasons); err != nil { return err } } if o.RegionMatch == "" { o.RegionMatch = RegionUnknown } if !IsRegionMatch(o.RegionMatch) { return fmt.Errorf("%w: unknown region_match %q", ErrValidation, o.RegionMatch) } if o.IntentBand == "" && (o.Status == OppQualified || o.Status == OppRejected || o.Status == OppAccepted || o.Status == OppDismissed) { o.ApplyBandFromScore() } if o.IntentBand != "" && !IsIntentBand(o.IntentBand) { return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand) } o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms) seenProducts := map[string]bool{} for _, match := range o.ProductMatches { if err := match.ValidateForWrite(); err != nil { return err } if seenProducts[match.ProductID] { return fmt.Errorf("%w: duplicate product match %q", ErrValidation, match.ProductID) } seenProducts[match.ProductID] = true } if o.PrimaryProductID != "" { var primary *ProductMatch for _, match := range o.ProductMatches { if match.ProductID == o.PrimaryProductID { primary = match break } } if primary == nil { return fmt.Errorf("%w: primary product must exist in product_matches", ErrValidation) } if (!primary.Eligible || primary.Excluded) && o.PrimaryProductOverride == nil { return fmt.Errorf("%w: weak or excluded primary requires override", ErrValidation) } } return nil } // UTCDayBounds returns [start, end) unix ns for the UTC calendar day that contains at. func UTCDayBounds(at int64) (start, end int64) { if at <= 0 { at = NowNano() } t := time.Unix(0, at).UTC() day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return day.UnixNano(), day.Add(24 * time.Hour).UnixNano() } // DisplayLocation is the product default timezone (Harbor Desk is Taipei-first). func DisplayLocation() *time.Location { return time.FixedZone("Asia/Taipei", 8*60*60) } // LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at. func LocalDayBounds(at int64, loc *time.Location) (start, end int64) { if loc == nil { loc = DisplayLocation() } if at <= 0 { at = NowNano() } t := time.Unix(0, at).In(loc) day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) return day.UnixNano(), day.Add(24 * time.Hour).UnixNano() } // InInboxTimeScope is true when a row belongs on today/7d. // Posted-in-range is the primary meaning; a just-finished sweep must still // surface older or undated posts via last_matched_at / created_at, otherwise // the default pending+today inbox looks empty after patrol. func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool { if from <= 0 && to <= 0 { return true } in := func(ts int64) bool { if ts <= 0 { return false } if from > 0 && ts < from { return false } if to > 0 && ts >= to { return false } return true } if in(postedAt) { return true } discovered := lastMatchedAt if discovered <= 0 { discovered = createdAt } return in(discovered) }