FloorVisualizer/internal/service/product_service.go
dindang 94895cbc22 Initial commit: FloorVisualizer backend
- Go API server with PostgreSQL + Redis
- AI floor replacement (OpenRouter Gemini)
- Product database (10 brands, 3539 products)
- Recommendation engine, calculator, articles
- Redis async queue + worker pool
- Hot product caching, brand view tracking
- JWT auth, favorites, projects
- Docker deployment ready

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 09:47:11 +08:00

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
}