package service import ( "math" "sort" "strings" "floorvisualizer/internal/model" ) type ProductService struct { products []model.Product config model.EngineConfig } func NewProductService(products []model.Product, config model.EngineConfig) *ProductService { if config.MinScore <= 0 { config.MinScore = 70 } if config.MaxResults <= 0 { config.MaxResults = 10 } return &ProductService{products: products, config: config} } func (s *ProductService) AllProducts() []model.Product { return s.products } func (s *ProductService) FindMatches(source model.Product) []model.MatchResult { candidates := make([]model.Product, 0) for _, p := range s.products { if p.SKU == source.SKU && p.Brand == source.Brand { continue } if p.Brand == source.Brand && p.StyleName == source.StyleName { continue } if p.Category != source.Category { continue } candidates = append(candidates, p) } // Score all candidates allResults := make([]model.MatchResult, 0, len(candidates)) for _, c := range candidates { score, detail := s.score(source, c) allResults = append(allResults, model.MatchResult{Product: c, Score: score, Details: detail}) } sort.Slice(allResults, func(i, j int) bool { return allResults[i].Score > allResults[j].Score }) // Prefer above MinScore, fill remaining with best below above := make([]model.MatchResult, 0) below := make([]model.MatchResult, 0) for _, r := range allResults { if r.Score >= s.config.MinScore { above = append(above, r) } else { below = append(below, r) } } // Always return at least 5 (or as many as available), filling gaps with best below-70 combined := above need := s.config.MaxResults - len(combined) for i := 0; i < need && i < len(below); i++ { combined = append(combined, below[i]) } if len(combined) == 0 { return nil } const maxPerBrand = 3 brandCount := map[string]int{} diverse := make([]model.MatchResult, 0, s.config.MaxResults) for _, r := range combined { if brandCount[r.Product.Brand] < maxPerBrand { brandCount[r.Product.Brand]++ diverse = append(diverse, r) } if len(diverse) >= s.config.MaxResults { break } } return diverse } func (s *ProductService) score(source, candidate model.Product) (float64, model.ScoreDetail) { w := s.config colorS := scoreColor(source.ColorTone, candidate.ColorTone) finishS := scoreFinish(source.Finish, candidate.Finish) visualScore := clamp((colorS*0.7 + finishS*0.3) * 100) materialScore := 0.0 if normalizeMaterial(source.Material) == normalizeMaterial(candidate.Material) { materialScore = 100 } else if sameMaterialFamily(source.Material, candidate.Material) { materialScore = 50 } priceScore := scorePrice(source, candidate) categoryScore := 100.0 hasPrice := source.PricePerSqft > 0 && candidate.PricePerSqft > 0 && source.PricePerSqft != 4.99 && candidate.PricePerSqft != 4.99 && source.PriceSource != "pending_review" && candidate.PriceSource != "pending_review" weightV, weightM, weightP, weightC := w.WeightVisual, w.WeightMaterial, w.WeightPrice, w.WeightCategory if !hasPrice { redist := weightP / 3 weightV += redist * 2 weightM += redist weightP = 0 } total := (visualScore*weightV + materialScore*weightM + priceScore*weightP + categoryScore*weightC) / 100.0 groupPenalty := 0.0 if source.GroupName != "" && candidate.GroupName != "" && source.GroupName == candidate.GroupName { if priceTierGap(source.PriceTier, candidate.PriceTier) >= 2 { groupPenalty = -10 } else { groupPenalty = -20 } total += groupPenalty } detail := model.ScoreDetail{ Visual: round(visualScore), Material: round(materialScore), Price: round(priceScore), Category: round(categoryScore), GroupPenalty: groupPenalty, } return round(total), detail } // ── Scoring helpers ───────────────────────────────────────── func scoreColor(source, candidate string) float64 { if source == "" || candidate == "" { return 0.5 } if source == candidate { return 1.0 } groups := [][]string{ {"Light Gray", "Off White", "Taupe"}, {"Dark Brown", "Black Walnut", "Taupe"}, {"Natural Oak", "Warm Honey", "Taupe"}, {"Cherry", "Dark Brown"}, {"Green", "Blue"}, } for _, g := range groups { if contains(g, source) && contains(g, candidate) { return 0.6 } } return 0.1 } func scoreFinish(source, candidate string) float64 { if source == "" || candidate == "" { return 0.5 } if source == candidate { return 1.0 } if (source == "Matte" && candidate == "Textured") || (source == "Textured" && candidate == "Matte") { return 0.6 } return 0.2 } func scorePrice(source model.Product, candidate model.Product) float64 { sp, cp := source.PricePerSqft, candidate.PricePerSqft ss, cs := source.PriceSource, candidate.PriceSource if ss == "pending_review" || cs == "pending_review" || ss == "estimated" || cs == "estimated" { return 50 } if cp <= 0 { return 50 } // If source has specs with different prices, use the closest-matching one if len(source.Specs) > 1 { bestDist := math.Abs(sp - cp) for _, s := range source.Specs { if s.PricePerSqft > 0 { d := math.Abs(s.PricePerSqft - cp) if d < bestDist { bestDist = d sp = s.PricePerSqft } } } } if sp <= 0 { return 50 } maxP := math.Max(sp, cp) if maxP == 0 { return 50 } return clamp(100 * (1 - math.Abs(sp-cp)/maxP)) } func normalizeMaterial(m string) string { for _, prefix := range []string{"Porcelain"} { if len(m) >= len(prefix) && m[:len(prefix)] == prefix { return prefix } } lower := strings.ToLower(m) if strings.Contains(lower, "rigid core") { return "Rigid Core" } if strings.Contains(lower, "luxury vinyl") || strings.Contains(lower, "vinyl") { return "LVT" } if strings.Contains(lower, "laminate") { return "Laminate" } return m } func sameMaterialFamily(a, b string) bool { aN, bN := normalizeMaterial(a), normalizeMaterial(b) woods := []string{"Oak", "Maple", "Hickory", "Walnut", "Cherry Wood", "Birch", "Pine", "Ash", "Acacia", "Elm", "Teak", "Bamboo", "Mahogany", "Chestnut", "Rosewood", "Ebony"} stones := []string{"Porcelain", "Stone Texture", "Marble", "Travertine", "Slate", "Cement"} vinyls := []string{"SPC", "LVT", "PVC", "Rigid Core", "Luxury Vinyl", "Vinyl"} if contains(woods, aN) && contains(woods, bN) { return true } if contains(stones, aN) && contains(stones, bN) { return true } if contains(vinyls, aN) && contains(vinyls, bN) { return true } return false } func priceTierGap(a, b string) int { rank := map[string]int{"": 0, "Budget/Value": 1, "Mid-Range": 2, "Upper Mid-Range": 3, "Premium": 4} ra, rb := rank[a], rank[b] diff := ra - rb if diff < 0 { diff = -diff } return diff } func round(v float64) float64 { return math.Round(v*10) / 10 } func clamp(v float64) float64 { return math.Max(0, math.Min(100, v)) } func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false }