231 lines
6.9 KiB
Go
231 lines
6.9 KiB
Go
|
|
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
|
||
|
|
}
|