2026-08-03 05:52:02 +00:00
|
|
|
|
package radarmap
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"apps/backend/internal/module/radar/domain"
|
|
|
|
|
|
"apps/backend/internal/types"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
func DemandMap(m *domain.DemandMap) *types.DemandMapPublic {
|
|
|
|
|
|
if m == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
phrases := func(in []domain.DemandMapPhrase) []types.DemandMapPhrase {
|
|
|
|
|
|
out := make([]types.DemandMapPhrase, 0, len(in))
|
|
|
|
|
|
for _, p := range in {
|
|
|
|
|
|
out = append(out, types.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled})
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
return &types.DemandMapPublic{
|
|
|
|
|
|
ProductId: m.ProductID, DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, State: m.State,
|
|
|
|
|
|
PainPhrases: phrases(m.PainPhrases), ScenarioPhrases: phrases(m.ScenarioPhrases), DesiredOutcomes: phrases(m.DesiredOutcomes),
|
|
|
|
|
|
SolutionSignals: phrases(m.SolutionSignals), ExclusionSignals: phrases(m.ExclusionSignals), SourceBasis: append([]string(nil), m.SourceBasis...),
|
|
|
|
|
|
CustomPhrases: phrases(m.CustomPhrases), AiEnrichedAt: m.AIEnrichedAt, UpdatedAt: m.UpdatedAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic {
|
|
|
|
|
|
if p == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &types.CostPreviewPublic{
|
|
|
|
|
|
PreviewId: p.PreviewID, Action: p.Action, KeyMode: p.KeyMode,
|
|
|
|
|
|
FixedCredits: p.FixedCredits, MinCredits: p.MinCredits, MaxCredits: p.MaxCredits,
|
|
|
|
|
|
SearchCalls: p.SearchCalls, MaxAiCandidates: p.MaxAICandidates,
|
|
|
|
|
|
RemainingCredits: p.RemainingCredits, EstimateBasis: p.EstimateBasis, ExpiresAt: p.ExpiresAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
/*
|
|
|
|
|
|
ServiceProfile 把 domain 檔案轉成 API 形狀。
|
|
|
|
|
|
|
|
|
|
|
|
p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。
|
|
|
|
|
|
但 exists 這個欄位必須誠實,訂閱閘門(SP-01)與前端引導都看它。
|
|
|
|
|
|
*/
|
|
|
|
|
|
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
|
|
|
|
|
|
if p == nil {
|
|
|
|
|
|
return &types.ServiceProfilePublic{
|
|
|
|
|
|
Exists: false,
|
|
|
|
|
|
Services: []types.ServiceItem{},
|
|
|
|
|
|
Cases: []types.ServiceCasePublic{},
|
|
|
|
|
|
Forbidden: []string{},
|
|
|
|
|
|
Faq: []types.FaqItem{},
|
|
|
|
|
|
ServiceAreas: []string{},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
services := make([]types.ServiceItem, 0, len(p.Services))
|
|
|
|
|
|
for _, s := range p.Services {
|
|
|
|
|
|
services = append(services, types.ServiceItem{
|
|
|
|
|
|
Name: s.Name,
|
|
|
|
|
|
PriceMin: s.PriceMin,
|
|
|
|
|
|
PriceMax: s.PriceMax,
|
|
|
|
|
|
Currency: s.Currency,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
cases := make([]types.ServiceCasePublic, 0, len(p.Cases))
|
|
|
|
|
|
for _, c := range p.Cases {
|
|
|
|
|
|
cases = append(cases, types.ServiceCasePublic{Title: c.Title, Summary: c.Summary, Link: c.Link})
|
|
|
|
|
|
}
|
|
|
|
|
|
faq := make([]types.FaqItem, 0, len(p.Faq))
|
|
|
|
|
|
for _, f := range p.Faq {
|
|
|
|
|
|
faq = append(faq, types.FaqItem{Question: f.Question, Answer: f.Answer})
|
|
|
|
|
|
}
|
|
|
|
|
|
forbidden := p.Forbidden
|
|
|
|
|
|
if forbidden == nil {
|
|
|
|
|
|
forbidden = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
areas := p.ServiceAreas
|
|
|
|
|
|
if areas == nil {
|
|
|
|
|
|
areas = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return &types.ServiceProfilePublic{
|
|
|
|
|
|
Exists: true,
|
|
|
|
|
|
Services: services,
|
|
|
|
|
|
Cases: cases,
|
|
|
|
|
|
Forbidden: forbidden,
|
|
|
|
|
|
Faq: faq,
|
|
|
|
|
|
ServiceAreas: areas,
|
|
|
|
|
|
RemoteOk: p.RemoteOk,
|
|
|
|
|
|
Availability: p.Availability,
|
|
|
|
|
|
ToneNote: p.ToneNote,
|
|
|
|
|
|
UpdatedAt: p.UpdatedAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
|
|
|
|
|
|
if w == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
terms, excludes, regions := w.Terms, w.ExcludeTerms, w.Regions
|
|
|
|
|
|
if terms == nil {
|
|
|
|
|
|
terms = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
if excludes == nil {
|
|
|
|
|
|
excludes = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
if regions == nil {
|
|
|
|
|
|
regions = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return &types.RadarWatchPublic{
|
2026-08-13 02:22:24 +00:00
|
|
|
|
Id: w.ID,
|
|
|
|
|
|
Terms: terms,
|
|
|
|
|
|
ExcludeTerms: excludes,
|
|
|
|
|
|
Regions: regions,
|
|
|
|
|
|
Status: w.Status,
|
|
|
|
|
|
ContextMode: func() string {
|
|
|
|
|
|
if w.ContextMode == "" {
|
|
|
|
|
|
return domain.WatchContextGeneric
|
|
|
|
|
|
}
|
|
|
|
|
|
return w.ContextMode
|
|
|
|
|
|
}(),
|
|
|
|
|
|
BrandId: w.BrandID, ProductId: w.ProductID,
|
|
|
|
|
|
BrandNameSnapshot: w.BrandNameSnapshot, ProductLabelSnapshot: w.ProductLabelSnapshot,
|
|
|
|
|
|
ContextBoundAt: w.ContextBoundAt, PauseReason: w.PauseReason,
|
2026-08-06 14:50:36 +00:00
|
|
|
|
LastSweptAt: w.LastSweptAt,
|
|
|
|
|
|
CreatedAt: w.CreatedAt,
|
|
|
|
|
|
UpdatedAt: w.UpdatedAt,
|
|
|
|
|
|
FirstSweepTriggered: w.FirstSweepTriggered,
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func WatchList(list []*domain.RadarWatch) []types.RadarWatchPublic {
|
|
|
|
|
|
out := make([]types.RadarWatchPublic, 0, len(list))
|
|
|
|
|
|
for _, w := range list {
|
|
|
|
|
|
if p := Watch(w); p != nil {
|
|
|
|
|
|
out = append(out, *p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Pagination(page, pageSize int, total int64) types.Pagination {
|
|
|
|
|
|
if page < 1 {
|
|
|
|
|
|
page = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if pageSize < 1 {
|
|
|
|
|
|
pageSize = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
tp := int(total) / pageSize
|
|
|
|
|
|
if int(total)%pageSize != 0 {
|
|
|
|
|
|
tp++
|
|
|
|
|
|
}
|
|
|
|
|
|
return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ServiceProfileInput 把 PUT 的 request 轉成 domain。owner_uid 一律由 logic 從 JWT 帶入。
|
|
|
|
|
|
func ServiceProfileInput(req *types.UpsertServiceProfileReq) *domain.ServiceProfile {
|
|
|
|
|
|
if req == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
services := make([]domain.ServiceItem, 0, len(req.Services))
|
|
|
|
|
|
for _, s := range req.Services {
|
|
|
|
|
|
services = append(services, domain.ServiceItem{
|
|
|
|
|
|
Name: s.Name,
|
|
|
|
|
|
PriceMin: s.PriceMin,
|
|
|
|
|
|
PriceMax: s.PriceMax,
|
|
|
|
|
|
Currency: s.Currency,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
cases := make([]domain.ServiceCase, 0, len(req.Cases))
|
|
|
|
|
|
for _, c := range req.Cases {
|
|
|
|
|
|
cases = append(cases, domain.ServiceCase{Title: c.Title, Summary: c.Summary, Link: c.Link})
|
|
|
|
|
|
}
|
|
|
|
|
|
faq := make([]domain.FaqItem, 0, len(req.Faq))
|
|
|
|
|
|
for _, f := range req.Faq {
|
|
|
|
|
|
faq = append(faq, domain.FaqItem{Question: f.Question, Answer: f.Answer})
|
|
|
|
|
|
}
|
|
|
|
|
|
return &domain.ServiceProfile{
|
|
|
|
|
|
Services: services,
|
|
|
|
|
|
Cases: cases,
|
|
|
|
|
|
Forbidden: req.Forbidden,
|
|
|
|
|
|
Faq: faq,
|
|
|
|
|
|
ServiceAreas: req.ServiceAreas,
|
|
|
|
|
|
RemoteOk: req.RemoteOk,
|
|
|
|
|
|
Availability: req.Availability,
|
|
|
|
|
|
ToneNote: req.ToneNote,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Opportunity(o *domain.Opportunity) *types.OpportunityPublic {
|
|
|
|
|
|
if o == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
reasons := make([]types.OpportunityReason, 0, len(o.Reasons))
|
|
|
|
|
|
for _, r := range o.Reasons {
|
|
|
|
|
|
reasons = append(reasons, types.OpportunityReason{Dimension: r.Dimension, Score: r.Score, Reason: r.Reason})
|
|
|
|
|
|
}
|
|
|
|
|
|
terms := o.MatchedTerms
|
|
|
|
|
|
if terms == nil {
|
|
|
|
|
|
terms = []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
out := &types.OpportunityPublic{
|
|
|
|
|
|
Id: o.ID, WatchId: o.WatchID, Source: o.Source, SourceScoutPostId: o.SourceScoutPostID,
|
|
|
|
|
|
ExternalId: o.ExternalID, Permalink: o.Permalink, AuthorHandle: o.AuthorHandle, Text: o.Text,
|
|
|
|
|
|
PostedAt: o.PostedAt, Status: o.Status, IntentScore: o.IntentScore, IntentBand: o.IntentBand,
|
|
|
|
|
|
Reasons: reasons, RegionDetected: o.RegionDetected, RegionMatch: o.RegionMatch,
|
|
|
|
|
|
FreshnessHours: o.FreshnessHours, MatchedService: o.MatchedService, MatchedTerms: terms,
|
|
|
|
|
|
RejectReason: o.RejectReason, ContactId: o.ContactID, CreatedAt: o.CreatedAt,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
PrimaryBrandId: o.PrimaryBrandID, PrimaryProductId: o.PrimaryProductID,
|
|
|
|
|
|
PrimaryBrandName: o.PrimaryBrandName, PrimaryProductLabel: o.PrimaryProductLabel,
|
|
|
|
|
|
PrimaryProductFitScore: o.PrimaryProductFitScore, PrimaryProductFitBand: o.PrimaryProductFitBand,
|
|
|
|
|
|
PrimaryProductOverridden: o.PrimaryProductOverridden,
|
|
|
|
|
|
ReviewState: reviewState(o), PreviousReviewState: o.PreviousReviewState,
|
|
|
|
|
|
RemovalReason: o.RemovalReason, RemovalNote: o.RemovalNote, RemovedAt: o.RemovedAt, RemovedBy: o.RemovedBy,
|
|
|
|
|
|
LastMatchedAt: o.LastMatchedAt,
|
|
|
|
|
|
PriorityScore: o.PriorityScore, PriorityBand: o.PriorityBand, PainFitScore: o.PainFitScore,
|
|
|
|
|
|
DemandIntentScore: o.DemandIntentScore, EvidenceQualityScore: o.EvidenceQualityScore,
|
|
|
|
|
|
FreshnessScore: o.FreshnessScore, DemandEvidence: nonNilStrings(o.DemandEvidence),
|
|
|
|
|
|
DemandInputVersion: o.DemandInputVersion, DemandMapVersion: o.DemandMapVersion,
|
|
|
|
|
|
}
|
|
|
|
|
|
out.ProductMatches = make([]types.ProductMatchPublic, 0, len(o.ProductMatches))
|
|
|
|
|
|
for _, match := range o.ProductMatches {
|
|
|
|
|
|
if match == nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
reasons := make([]types.ProductFitReasonPublic, 0, len(match.Reasons))
|
|
|
|
|
|
for _, reason := range match.Reasons {
|
|
|
|
|
|
reasons = append(reasons, types.ProductFitReasonPublic{Dimension: reason.Dimension, Score: reason.Score, Reason: reason.Reason, CandidateExcerpt: reason.CandidateExcerpt, ProductBasis: reason.ProductBasis})
|
|
|
|
|
|
}
|
|
|
|
|
|
out.ProductMatches = append(out.ProductMatches, types.ProductMatchPublic{BrandId: match.BrandID, ProductId: match.ProductID, BrandNameSnapshot: match.BrandNameSnapshot, ProductLabelSnapshot: match.ProductLabelSnapshot, BrandUpdatedAt: match.BrandUpdatedAt, ProductUpdatedAt: match.ProductUpdatedAt, ProductFitScore: match.ProductFitScore, ProductFitBand: match.ProductFitBand, Eligible: match.Eligible, Excluded: match.Excluded, ExcludeReason: match.ExcludeReason, Reasons: reasons, Risks: nonNilStrings(match.Risks), WatchIds: nonNilStrings(match.WatchIDs), MatchedTerms: nonNilStrings(match.MatchedTerms), MatchedAt: match.MatchedAt})
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
if o.Override != nil {
|
|
|
|
|
|
out.Override = &types.OpportunityOverride{
|
|
|
|
|
|
FromBand: o.Override.FromBand, ToBand: o.Override.ToBand,
|
|
|
|
|
|
FromStatus: o.Override.FromStatus, ToStatus: o.Override.ToStatus,
|
|
|
|
|
|
ActorUid: o.Override.ActorUID, At: o.Override.At,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
func reviewState(o *domain.Opportunity) string {
|
|
|
|
|
|
if o.ReviewState != "" {
|
|
|
|
|
|
return o.ReviewState
|
|
|
|
|
|
}
|
|
|
|
|
|
switch o.Status {
|
|
|
|
|
|
case domain.OppAccepted:
|
|
|
|
|
|
return domain.ReviewCompleted
|
|
|
|
|
|
case domain.OppDismissed:
|
|
|
|
|
|
return domain.ReviewRemoved
|
|
|
|
|
|
default:
|
|
|
|
|
|
return domain.ReviewPending
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func nonNilStrings(in []string) []string {
|
|
|
|
|
|
if in == nil {
|
|
|
|
|
|
return []string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return in
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
func OpportunityList(list []*domain.Opportunity) []types.OpportunityPublic {
|
|
|
|
|
|
out := make([]types.OpportunityPublic, 0, len(list))
|
|
|
|
|
|
for _, o := range list {
|
|
|
|
|
|
if p := Opportunity(o); p != nil {
|
|
|
|
|
|
out = append(out, *p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Sweep(s *domain.RadarSweep) *types.RadarSweepPublic {
|
|
|
|
|
|
if s == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
status := "complete"
|
|
|
|
|
|
if s.FailedReason != "" {
|
|
|
|
|
|
status = "failed"
|
|
|
|
|
|
} else if s.TruncatedCount > 0 || s.BudgetDeferredCount > 0 {
|
|
|
|
|
|
status = "partial_budget"
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
return &types.RadarSweepPublic{
|
|
|
|
|
|
Id: s.ID, WatchId: s.WatchID, JobId: s.JobID, Path: s.Path,
|
|
|
|
|
|
HitCount: s.HitCount, JudgedCount: s.JudgedCount, CreatedCount: s.CreatedCount,
|
|
|
|
|
|
TruncatedCount: s.TruncatedCount, FailedReason: s.FailedReason, CreditsUsed: s.CreditsUsed,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
MatchEvaluatedCount: s.MatchEvaluatedCount, MatchMergedCount: s.MatchMergedCount, FitRejectedCount: s.FitRejectedCount,
|
|
|
|
|
|
DedupedCount: s.DedupedCount, PrefilterPassCount: s.PrefilterPassCount, PrefilterReviewCount: s.PrefilterReviewCount,
|
|
|
|
|
|
PrefilterRejectedCount: s.PrefilterRejectedCount, CachedJudgmentCount: s.CachedJudgmentCount,
|
|
|
|
|
|
TombstoneMatchedCount: s.TombstoneMatchedCount, BudgetDeferredCount: s.BudgetDeferredCount,
|
|
|
|
|
|
DemandInputVersion: s.DemandInputVersion, DemandMapVersion: s.DemandMapVersion,
|
|
|
|
|
|
CreditSearch: s.CreditSearch, CreditDemandMap: s.CreditDemandMap, CreditJudge: s.CreditJudge, CreditReply: s.CreditReply,
|
|
|
|
|
|
SweepStatus: status,
|
|
|
|
|
|
StartedAt: s.StartedAt, EndedAt: s.EndedAt,
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func SweepList(list []*domain.RadarSweep) []types.RadarSweepPublic {
|
|
|
|
|
|
out := make([]types.RadarSweepPublic, 0, len(list))
|
|
|
|
|
|
for _, s := range list {
|
|
|
|
|
|
if p := Sweep(s); p != nil {
|
|
|
|
|
|
out = append(out, *p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func Reply(r *domain.ReplyVariant) *types.ReplyVariantPublic {
|
|
|
|
|
|
if r == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return &types.ReplyVariantPublic{
|
|
|
|
|
|
Id: r.ID, OpportunityId: r.OpportunityID, Variant: r.Variant, Text: r.Text,
|
2026-08-09 07:57:35 +00:00
|
|
|
|
UsedAt: r.UsedAt, SentChannel: r.SentChannel, OutboxId: r.OutboxID, CreatedAt: r.CreatedAt,
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func ReplyList(list []*domain.ReplyVariant) []types.ReplyVariantPublic {
|
|
|
|
|
|
out := make([]types.ReplyVariantPublic, 0, len(list))
|
|
|
|
|
|
for _, r := range list {
|
|
|
|
|
|
if p := Reply(r); p != nil {
|
|
|
|
|
|
out = append(out, *p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|