714 lines
20 KiB
Go
714 lines
20 KiB
Go
|
|
package handler
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"math"
|
||
|
|
"net/http"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"floorvisualizer/internal/model"
|
||
|
|
"floorvisualizer/internal/repository"
|
||
|
|
)
|
||
|
|
|
||
|
|
type QuestionnaireBudget struct {
|
||
|
|
Min float64 `json:"min"`
|
||
|
|
Max float64 `json:"max"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type QuestionnaireRequest struct {
|
||
|
|
Rooms []string `json:"rooms"`
|
||
|
|
Styles []string `json:"styles"`
|
||
|
|
Looks []string `json:"looks"`
|
||
|
|
ColorTones []string `json:"color_tones"`
|
||
|
|
Budget QuestionnaireBudget `json:"budget"`
|
||
|
|
Brands []string `json:"brands"`
|
||
|
|
BrandMode string `json:"brand_mode"`
|
||
|
|
Performance []string `json:"performance"`
|
||
|
|
Finishes []string `json:"finishes"`
|
||
|
|
SizePreference string `json:"size_preference"`
|
||
|
|
Priority string `json:"priority"`
|
||
|
|
Answers map[string]any `json:"answers,omitempty"`
|
||
|
|
Limit int `json:"limit"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type QuestionnaireRecommendation struct {
|
||
|
|
Product model.Product `json:"product"`
|
||
|
|
Score float64 `json:"score"`
|
||
|
|
Reasons []string `json:"reasons"`
|
||
|
|
SpecCount int `json:"spec_count"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type QuestionnaireResponse struct {
|
||
|
|
Recommendations []QuestionnaireRecommendation `json:"recommendations"`
|
||
|
|
TotalCandidates int `json:"total_candidates"`
|
||
|
|
UniqueCandidates int `json:"unique_candidates"`
|
||
|
|
AnsweredWeights float64 `json:"answered_weights"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type questionnaireScoredProduct struct {
|
||
|
|
product model.Product
|
||
|
|
score float64
|
||
|
|
reasons []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func QuestionnaireOptions(db *sql.DB) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if db == nil {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "database is not available")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
out := map[string]any{
|
||
|
|
"brands": queryOptionCounts(db, "brand", 20),
|
||
|
|
"categories": queryOptionCounts(db, "category", 20),
|
||
|
|
"materials": queryOptionCounts(db, "material", 40),
|
||
|
|
"color_tones": queryOptionCounts(db, "color_tone", 30),
|
||
|
|
"finishes": queryOptionCounts(db, "finish", 20),
|
||
|
|
"price_stats": queryPriceStats(db),
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func QuestionnaireRecommend(db *sql.DB) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if db == nil {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "database is not available")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var req QuestionnaireRequest
|
||
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "invalid questionnaire payload")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
normalizeQuestionnaireRequest(&req)
|
||
|
|
if req.Limit <= 0 || req.Limit > 30 {
|
||
|
|
req.Limit = 10
|
||
|
|
}
|
||
|
|
|
||
|
|
products, err := repository.QueryAllProductRows(db)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
brandOnly := req.BrandMode == "only" && len(req.Brands) > 0
|
||
|
|
brandSet := toSet(req.Brands)
|
||
|
|
scored := make([]questionnaireScoredProduct, 0, len(products))
|
||
|
|
for _, p := range products {
|
||
|
|
if !recommendableProduct(p) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if brandOnly && !brandSet[p.Brand] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
score, reasons, answeredWeight := scoreQuestionnaireProduct(req, p)
|
||
|
|
if answeredWeight == 0 {
|
||
|
|
score = neutralProductScore(p)
|
||
|
|
}
|
||
|
|
scored = append(scored, questionnaireScoredProduct{product: p, score: roundOne(score), reasons: reasons})
|
||
|
|
}
|
||
|
|
|
||
|
|
unique := dedupeQuestionnaireResults(scored)
|
||
|
|
sort.SliceStable(unique, func(i, j int) bool {
|
||
|
|
if unique[i].score != unique[j].score {
|
||
|
|
return unique[i].score > unique[j].score
|
||
|
|
}
|
||
|
|
if unique[i].product.IsOfficialPrice != unique[j].product.IsOfficialPrice {
|
||
|
|
return unique[i].product.IsOfficialPrice
|
||
|
|
}
|
||
|
|
if req.Priority == "budget" && unique[i].product.PricePerSqft != unique[j].product.PricePerSqft {
|
||
|
|
return unique[i].product.PricePerSqft < unique[j].product.PricePerSqft
|
||
|
|
}
|
||
|
|
return unique[i].product.MainImageURL != "" && unique[j].product.MainImageURL == ""
|
||
|
|
})
|
||
|
|
|
||
|
|
selected := diversifyQuestionnaireResults(unique, req.Limit, brandOnly)
|
||
|
|
skus := make([]string, 0, len(selected))
|
||
|
|
for _, r := range selected {
|
||
|
|
skus = append(skus, r.product.SKU)
|
||
|
|
}
|
||
|
|
specCounts := repository.GetSpecCounts(db, skus)
|
||
|
|
|
||
|
|
recs := make([]QuestionnaireRecommendation, 0, len(selected))
|
||
|
|
for _, r := range selected {
|
||
|
|
key := r.product.Brand + "|" + r.product.StyleName + "|" + r.product.SeriesName
|
||
|
|
recs = append(recs, QuestionnaireRecommendation{
|
||
|
|
Product: r.product,
|
||
|
|
Score: r.score,
|
||
|
|
Reasons: fallbackReasons(r.reasons, r.product),
|
||
|
|
SpecCount: specCounts[key],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
_, _, answeredWeight := scoreQuestionnaireProduct(req, model.Product{})
|
||
|
|
writeJSON(w, http.StatusOK, QuestionnaireResponse{
|
||
|
|
Recommendations: recs,
|
||
|
|
TotalCandidates: len(scored),
|
||
|
|
UniqueCandidates: len(unique),
|
||
|
|
AnsweredWeights: roundOne(answeredWeight),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeQuestionnaireRequest(req *QuestionnaireRequest) {
|
||
|
|
req.Rooms = cleanList(req.Rooms)
|
||
|
|
req.Styles = cleanList(req.Styles)
|
||
|
|
req.Looks = cleanList(req.Looks)
|
||
|
|
req.ColorTones = cleanList(req.ColorTones)
|
||
|
|
req.Brands = cleanList(req.Brands)
|
||
|
|
req.Performance = cleanList(req.Performance)
|
||
|
|
req.Finishes = cleanList(req.Finishes)
|
||
|
|
req.SizePreference = strings.TrimSpace(req.SizePreference)
|
||
|
|
req.Priority = strings.TrimSpace(req.Priority)
|
||
|
|
req.BrandMode = strings.TrimSpace(req.BrandMode)
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreQuestionnaireProduct(req QuestionnaireRequest, p model.Product) (float64, []string, float64) {
|
||
|
|
weights := map[string]float64{
|
||
|
|
"color": 20,
|
||
|
|
"style": 20,
|
||
|
|
"look": 15,
|
||
|
|
"budget": 15,
|
||
|
|
"room": 10,
|
||
|
|
"performance": 10,
|
||
|
|
"brand": 7,
|
||
|
|
"finish": 5,
|
||
|
|
"size": 3,
|
||
|
|
}
|
||
|
|
adjustWeights(weights, req.Priority)
|
||
|
|
|
||
|
|
totalWeight := 0.0
|
||
|
|
weighted := 0.0
|
||
|
|
reasons := []string{}
|
||
|
|
|
||
|
|
add := func(name string, answered bool, score float64, reason string) {
|
||
|
|
if !answered {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w := weights[name]
|
||
|
|
totalWeight += w
|
||
|
|
weighted += clampScore(score) * w
|
||
|
|
if score >= 78 && reason != "" {
|
||
|
|
reasons = append(reasons, reason)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
add("color", len(req.ColorTones) > 0, scoreExactOrRelated(p.ColorTone, req.ColorTones, colorRelatedGroups()), fmt.Sprintf("颜色接近 %s", p.ColorTone))
|
||
|
|
add("style", len(req.Styles) > 0, scoreStyles(p, req.Styles), "风格偏好匹配")
|
||
|
|
add("look", len(req.Looks) > 0, scoreLooks(p, req.Looks), "外观类型匹配")
|
||
|
|
add("budget", req.Budget.Min > 0 || req.Budget.Max > 0, scoreBudget(p.PricePerSqft, req.Budget), priceReason(p))
|
||
|
|
add("room", len(req.Rooms) > 0, scoreRooms(p, req.Rooms), "适合选择的使用空间")
|
||
|
|
add("performance", len(req.Performance) > 0, scorePerformance(p, req.Performance), "性能偏好匹配")
|
||
|
|
add("brand", len(req.Brands) > 0 && req.BrandMode != "only", scoreExactOrRelated(p.Brand, req.Brands, nil), fmt.Sprintf("匹配品牌 %s", p.Brand))
|
||
|
|
add("finish", len(req.Finishes) > 0, scoreExactOrRelated(p.Finish, req.Finishes, [][]string{{"Matte", "Textured"}, {"Distressed/Embossed", "Wire Brushed"}}), fmt.Sprintf("表面质感为 %s", p.Finish))
|
||
|
|
add("size", req.SizePreference != "", scoreSize(p, req.SizePreference), "规格视觉匹配")
|
||
|
|
|
||
|
|
if totalWeight == 0 {
|
||
|
|
return 0, reasons, 0
|
||
|
|
}
|
||
|
|
|
||
|
|
score := weighted / totalWeight
|
||
|
|
if p.MainImageURL != "" || p.RoomImageURL != "" {
|
||
|
|
score += 1.5
|
||
|
|
}
|
||
|
|
if p.IsOfficialPrice {
|
||
|
|
score += 1
|
||
|
|
}
|
||
|
|
return clampScore(score), compactReasons(reasons), totalWeight
|
||
|
|
}
|
||
|
|
|
||
|
|
func adjustWeights(weights map[string]float64, priority string) {
|
||
|
|
switch priority {
|
||
|
|
case "style":
|
||
|
|
weights["style"] += 8
|
||
|
|
weights["color"] += 4
|
||
|
|
case "budget":
|
||
|
|
weights["budget"] += 12
|
||
|
|
case "durability":
|
||
|
|
weights["performance"] += 10
|
||
|
|
weights["room"] += 4
|
||
|
|
case "brand":
|
||
|
|
weights["brand"] += 12
|
||
|
|
case "color":
|
||
|
|
weights["color"] += 12
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func recommendableProduct(p model.Product) bool {
|
||
|
|
status := strings.ToLower(strings.TrimSpace(p.Status))
|
||
|
|
if strings.Contains(status, "discontinued") || strings.Contains(status, "inactive") || strings.Contains(status, "下架") {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return p.SKU != "" && p.StyleName != ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func neutralProductScore(p model.Product) float64 {
|
||
|
|
score := 60.0
|
||
|
|
if p.MainImageURL != "" || p.RoomImageURL != "" {
|
||
|
|
score += 8
|
||
|
|
}
|
||
|
|
if p.PricePerSqft > 0 {
|
||
|
|
score += 5
|
||
|
|
}
|
||
|
|
if p.IsOfficialPrice {
|
||
|
|
score += 3
|
||
|
|
}
|
||
|
|
return score
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreExactOrRelated(value string, selected []string, related [][]string) float64 {
|
||
|
|
if value == "" || len(selected) == 0 {
|
||
|
|
return 50
|
||
|
|
}
|
||
|
|
for _, s := range selected {
|
||
|
|
if strings.EqualFold(value, s) {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, group := range related {
|
||
|
|
if containsFold(group, value) {
|
||
|
|
for _, s := range selected {
|
||
|
|
if containsFold(group, s) {
|
||
|
|
return 70
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 15
|
||
|
|
}
|
||
|
|
|
||
|
|
func colorRelatedGroups() [][]string {
|
||
|
|
return [][]string{
|
||
|
|
{"Natural", "Warm Yellow", "Taupe"},
|
||
|
|
{"Light Gray", "Off White", "Taupe"},
|
||
|
|
{"Dark Brown", "Black Walnut", "Cherry"},
|
||
|
|
{"Green", "Blue", "Light Gray"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreStyles(p model.Product, styles []string) float64 {
|
||
|
|
best := 0.0
|
||
|
|
for _, style := range styles {
|
||
|
|
best = math.Max(best, scoreStyle(p, style))
|
||
|
|
}
|
||
|
|
return best
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreStyle(p model.Product, style string) float64 {
|
||
|
|
text := productText(p)
|
||
|
|
switch style {
|
||
|
|
case "modern":
|
||
|
|
return tagScore(p, []string{"Light Gray", "Off White", "Natural"}, []string{"Maple", "Ash", "Porcelain"}, []string{"modern", "clean", "minimal", "airy", "sophisticated"}, text)
|
||
|
|
case "warm_natural":
|
||
|
|
return tagScore(p, []string{"Natural", "Warm Yellow", "Taupe"}, []string{"Oak", "Hickory", "Pine", "Ash"}, []string{"warm", "natural", "wood", "honey"}, text)
|
||
|
|
case "classic":
|
||
|
|
return tagScore(p, []string{"Cherry", "Dark Brown", "Black Walnut", "Natural"}, []string{"Oak", "Walnut", "Maple", "Hickory"}, []string{"classic", "traditional", "timeless"}, text)
|
||
|
|
case "rustic":
|
||
|
|
score := tagScore(p, []string{"Natural", "Dark Brown", "Taupe"}, []string{"Hickory", "Oak", "Pine"}, []string{"rustic", "scraped", "weathered", "reclaimed", "vintage", "barn"}, text)
|
||
|
|
if p.Finish == "Distressed/Embossed" || p.Finish == "Wire Brushed" {
|
||
|
|
score += 20
|
||
|
|
}
|
||
|
|
return clampScore(score)
|
||
|
|
case "luxury_stone":
|
||
|
|
score := tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Porcelain", "Stone Look"}, []string{"marble", "travertine", "polished", "stone", "luxe"}, text)
|
||
|
|
if p.Category == "Tile/Stone" {
|
||
|
|
score += 25
|
||
|
|
}
|
||
|
|
if p.Finish == "Gloss" {
|
||
|
|
score += 10
|
||
|
|
}
|
||
|
|
return clampScore(score)
|
||
|
|
case "industrial":
|
||
|
|
return tagScore(p, []string{"Light Gray", "Black Walnut", "Dark Brown"}, []string{"Porcelain", "Stone Look"}, []string{"concrete", "slate", "smoke", "graphite", "cement", "industrial"}, text)
|
||
|
|
case "coastal":
|
||
|
|
return tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Oak", "Maple", "Ash"}, []string{"beach", "coastal", "sand", "airy", "white"}, text)
|
||
|
|
}
|
||
|
|
return 50
|
||
|
|
}
|
||
|
|
|
||
|
|
func tagScore(p model.Product, colors, materials, keywords []string, text string) float64 {
|
||
|
|
score := 15.0
|
||
|
|
if containsFold(colors, p.ColorTone) {
|
||
|
|
score += 30
|
||
|
|
}
|
||
|
|
if containsFold(materials, p.Material) || materialFamilyHit(p.Material, materials) {
|
||
|
|
score += 25
|
||
|
|
}
|
||
|
|
for _, kw := range keywords {
|
||
|
|
if strings.Contains(text, kw) {
|
||
|
|
score += 15
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if p.Finish == "Matte" {
|
||
|
|
score += 8
|
||
|
|
}
|
||
|
|
return clampScore(score)
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreLooks(p model.Product, looks []string) float64 {
|
||
|
|
best := 0.0
|
||
|
|
text := productText(p)
|
||
|
|
for _, look := range looks {
|
||
|
|
score := 0.0
|
||
|
|
switch look {
|
||
|
|
case "wood_look":
|
||
|
|
if isWoodLike(p, text) {
|
||
|
|
score = 100
|
||
|
|
} else if p.Category == "SPC-LVP" || p.Category == "Laminate" {
|
||
|
|
score = 70
|
||
|
|
} else {
|
||
|
|
score = 20
|
||
|
|
}
|
||
|
|
case "stone_tile":
|
||
|
|
if p.Category == "Tile/Stone" || strings.Contains(text, "stone") || strings.Contains(text, "marble") || strings.Contains(text, "travertine") {
|
||
|
|
score = 100
|
||
|
|
} else {
|
||
|
|
score = 15
|
||
|
|
}
|
||
|
|
case "real_hardwood":
|
||
|
|
if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" {
|
||
|
|
score = 100
|
||
|
|
} else if isWoodLike(p, text) {
|
||
|
|
score = 55
|
||
|
|
} else {
|
||
|
|
score = 10
|
||
|
|
}
|
||
|
|
case "light_clean":
|
||
|
|
score = scoreExactOrRelated(p.ColorTone, []string{"Off White", "Light Gray", "Natural"}, colorRelatedGroups())
|
||
|
|
case "dark_rich":
|
||
|
|
score = scoreExactOrRelated(p.ColorTone, []string{"Dark Brown", "Black Walnut", "Cherry"}, colorRelatedGroups())
|
||
|
|
}
|
||
|
|
best = math.Max(best, score)
|
||
|
|
}
|
||
|
|
return best
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreBudget(price float64, budget QuestionnaireBudget) float64 {
|
||
|
|
if price <= 0 {
|
||
|
|
return 50
|
||
|
|
}
|
||
|
|
min, max := budget.Min, budget.Max
|
||
|
|
if min > 0 && price < min {
|
||
|
|
return clampScore(100 - ((min-price)/math.Max(min, 1))*80)
|
||
|
|
}
|
||
|
|
if max > 0 && price > max {
|
||
|
|
return clampScore(100 - ((price-max)/math.Max(max, 1))*90)
|
||
|
|
}
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreRooms(p model.Product, rooms []string) float64 {
|
||
|
|
best := 0.0
|
||
|
|
for _, room := range rooms {
|
||
|
|
score := 50.0
|
||
|
|
switch room {
|
||
|
|
case "bathroom", "kitchen", "basement", "commercial":
|
||
|
|
score = scorePerformance(p, []string{"waterproof", "easy_clean"})
|
||
|
|
case "living_room", "bedroom":
|
||
|
|
score = math.Max(scoreStyles(p, []string{"warm_natural", "modern"}), 60)
|
||
|
|
case "rental":
|
||
|
|
score = scorePerformance(p, []string{"scratch_resistant", "easy_clean"})
|
||
|
|
}
|
||
|
|
best = math.Max(best, score)
|
||
|
|
}
|
||
|
|
return best
|
||
|
|
}
|
||
|
|
|
||
|
|
func scorePerformance(p model.Product, performance []string) float64 {
|
||
|
|
best := 0.0
|
||
|
|
text := productText(p)
|
||
|
|
for _, perf := range performance {
|
||
|
|
score := 20.0
|
||
|
|
switch perf {
|
||
|
|
case "waterproof":
|
||
|
|
if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || containsAnyFold(p.Material, []string{"porcelain", "vinyl", "rigid core", "spc"}) || strings.Contains(text, "water") || strings.Contains(text, "moisture") {
|
||
|
|
score = 100
|
||
|
|
}
|
||
|
|
case "scratch_resistant":
|
||
|
|
if p.Category == "Tile/Stone" || p.Category == "Laminate" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"scratch", "durable", "superguard", "wear"}) {
|
||
|
|
score = 95
|
||
|
|
}
|
||
|
|
case "easy_clean":
|
||
|
|
if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || p.Finish == "Matte" || containsAnyFold(text, []string{"clean", "maintenance"}) {
|
||
|
|
score = 90
|
||
|
|
}
|
||
|
|
case "pet_kid":
|
||
|
|
score = math.Max(scorePerformance(p, []string{"waterproof"}), scorePerformance(p, []string{"scratch_resistant"}))
|
||
|
|
case "comfort_quiet":
|
||
|
|
if p.Brand == "COREtec" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"quiet", "comfort", "soft", "underlayment"}) {
|
||
|
|
score = 90
|
||
|
|
}
|
||
|
|
case "eco":
|
||
|
|
if containsAnyFold(text, []string{"recycled", "eco", "green", "low voc"}) {
|
||
|
|
score = 90
|
||
|
|
} else {
|
||
|
|
score = 45
|
||
|
|
}
|
||
|
|
}
|
||
|
|
best = math.Max(best, score)
|
||
|
|
}
|
||
|
|
return best
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreSize(p model.Product, pref string) float64 {
|
||
|
|
switch pref {
|
||
|
|
case "wide_plank":
|
||
|
|
if p.WidthIn >= 7 && p.LengthIn >= 48 {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
if p.WidthIn >= 5 {
|
||
|
|
return 70
|
||
|
|
}
|
||
|
|
case "standard_plank":
|
||
|
|
if p.WidthIn >= 4 && p.WidthIn < 7 && p.LengthIn >= 36 {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
return 55
|
||
|
|
case "large_tile":
|
||
|
|
if p.Category == "Tile/Stone" && p.WidthIn >= 12 && p.LengthIn >= 24 {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
if p.Category == "Tile/Stone" {
|
||
|
|
return 65
|
||
|
|
}
|
||
|
|
case "small_tile":
|
||
|
|
if p.Category == "Tile/Stone" && (p.WidthIn <= 6 || p.LengthIn <= 12) {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
if p.Category == "Tile/Stone" {
|
||
|
|
return 60
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 20
|
||
|
|
}
|
||
|
|
|
||
|
|
func dedupeQuestionnaireResults(results []questionnaireScoredProduct) []questionnaireScoredProduct {
|
||
|
|
best := map[string]questionnaireScoredProduct{}
|
||
|
|
for _, r := range results {
|
||
|
|
key := strings.ToLower(r.product.Brand + "|" + r.product.SeriesName + "|" + r.product.StyleName + "|" + r.product.ColorTone)
|
||
|
|
if old, ok := best[key]; !ok || r.score > old.score || (r.score == old.score && r.product.MainImageURL != "" && old.product.MainImageURL == "") {
|
||
|
|
best[key] = r
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out := make([]questionnaireScoredProduct, 0, len(best))
|
||
|
|
for _, r := range best {
|
||
|
|
out = append(out, r)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func diversifyQuestionnaireResults(results []questionnaireScoredProduct, limit int, brandOnly bool) []questionnaireScoredProduct {
|
||
|
|
if len(results) <= limit {
|
||
|
|
return results
|
||
|
|
}
|
||
|
|
maxPerBrand := 4
|
||
|
|
if brandOnly {
|
||
|
|
maxPerBrand = limit
|
||
|
|
}
|
||
|
|
brandCounts := map[string]int{}
|
||
|
|
selected := make([]questionnaireScoredProduct, 0, limit)
|
||
|
|
for _, r := range results {
|
||
|
|
if brandCounts[r.product.Brand] >= maxPerBrand {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
selected = append(selected, r)
|
||
|
|
brandCounts[r.product.Brand]++
|
||
|
|
if len(selected) == limit {
|
||
|
|
return selected
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, r := range results {
|
||
|
|
exists := false
|
||
|
|
for _, s := range selected {
|
||
|
|
if s.product.SKU == r.product.SKU {
|
||
|
|
exists = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !exists {
|
||
|
|
selected = append(selected, r)
|
||
|
|
}
|
||
|
|
if len(selected) == limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return selected
|
||
|
|
}
|
||
|
|
|
||
|
|
func fallbackReasons(reasons []string, p model.Product) []string {
|
||
|
|
reasons = compactReasons(reasons)
|
||
|
|
if len(reasons) > 0 {
|
||
|
|
return reasons
|
||
|
|
}
|
||
|
|
out := []string{}
|
||
|
|
if p.ColorTone != "" {
|
||
|
|
out = append(out, "颜色为 "+p.ColorTone)
|
||
|
|
}
|
||
|
|
if p.Material != "" {
|
||
|
|
out = append(out, "材质为 "+p.Material)
|
||
|
|
}
|
||
|
|
if p.PricePerSqft > 0 {
|
||
|
|
out = append(out, priceReason(p))
|
||
|
|
}
|
||
|
|
return compactReasons(out)
|
||
|
|
}
|
||
|
|
|
||
|
|
func compactReasons(reasons []string) []string {
|
||
|
|
seen := map[string]bool{}
|
||
|
|
out := []string{}
|
||
|
|
for _, r := range reasons {
|
||
|
|
r = strings.TrimSpace(r)
|
||
|
|
if r == "" || seen[r] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[r] = true
|
||
|
|
out = append(out, r)
|
||
|
|
if len(out) >= 4 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func priceReason(p model.Product) string {
|
||
|
|
if p.PricePerSqft <= 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("价格约 $%.2f/sqft", p.PricePerSqft)
|
||
|
|
}
|
||
|
|
|
||
|
|
func productText(p model.Product) string {
|
||
|
|
return strings.ToLower(strings.Join([]string{
|
||
|
|
p.Brand, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.Description,
|
||
|
|
}, " "))
|
||
|
|
}
|
||
|
|
|
||
|
|
func isWoodLike(p model.Product, text string) bool {
|
||
|
|
if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" || p.Category == "Laminate" || p.Category == "SPC-LVP" {
|
||
|
|
if materialFamilyHit(p.Material, []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak", "Birch", "Luxury Vinyl", "Rigid Core"}) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return containsAnyFold(text, []string{"oak", "maple", "hickory", "walnut", "ash", "pine", "wood", "plank"})
|
||
|
|
}
|
||
|
|
|
||
|
|
func materialFamilyHit(material string, choices []string) bool {
|
||
|
|
m := strings.ToLower(material)
|
||
|
|
for _, choice := range choices {
|
||
|
|
c := strings.ToLower(choice)
|
||
|
|
if m == c || strings.Contains(m, c) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func queryOptionCounts(db *sql.DB, col string, limit int) []map[string]any {
|
||
|
|
rows, err := db.Query("SELECT "+col+", COUNT(*) FROM products WHERE "+col+" != '' GROUP BY "+col+" ORDER BY COUNT(*) DESC, "+col+" LIMIT $1", limit)
|
||
|
|
if err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := []map[string]any{}
|
||
|
|
for rows.Next() {
|
||
|
|
var value string
|
||
|
|
var count int
|
||
|
|
if err := rows.Scan(&value, &count); err == nil {
|
||
|
|
out = append(out, map[string]any{"value": value, "count": count})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func queryPriceStats(db *sql.DB) map[string]float64 {
|
||
|
|
rows, err := db.Query(`
|
||
|
|
SELECT
|
||
|
|
COALESCE(MIN(price_per_sqft), 0),
|
||
|
|
COALESCE(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||
|
|
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||
|
|
COALESCE(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||
|
|
COALESCE(MAX(price_per_sqft), 0)
|
||
|
|
FROM products WHERE price_per_sqft > 0
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return map[string]float64{}
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
stats := map[string]float64{}
|
||
|
|
if rows.Next() {
|
||
|
|
var min, p25, median, p75, max float64
|
||
|
|
if err := rows.Scan(&min, &p25, &median, &p75, &max); err == nil {
|
||
|
|
stats["min"] = roundOne(min)
|
||
|
|
stats["p25"] = roundOne(p25)
|
||
|
|
stats["median"] = roundOne(median)
|
||
|
|
stats["p75"] = roundOne(p75)
|
||
|
|
stats["max"] = roundOne(max)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return stats
|
||
|
|
}
|
||
|
|
|
||
|
|
func cleanList(values []string) []string {
|
||
|
|
out := []string{}
|
||
|
|
seen := map[string]bool{}
|
||
|
|
for _, v := range values {
|
||
|
|
v = strings.TrimSpace(v)
|
||
|
|
if v == "" || seen[v] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[v] = true
|
||
|
|
out = append(out, v)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func toSet(values []string) map[string]bool {
|
||
|
|
out := map[string]bool{}
|
||
|
|
for _, v := range values {
|
||
|
|
out[v] = true
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsFold(values []string, target string) bool {
|
||
|
|
for _, v := range values {
|
||
|
|
if strings.EqualFold(v, target) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsAnyFold(value string, needles []string) bool {
|
||
|
|
v := strings.ToLower(value)
|
||
|
|
for _, n := range needles {
|
||
|
|
if strings.Contains(v, strings.ToLower(n)) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func clampScore(v float64) float64 {
|
||
|
|
if v < 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
if v > 100 {
|
||
|
|
return 100
|
||
|
|
}
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
|
||
|
|
func roundOne(v float64) float64 {
|
||
|
|
return math.Round(v*10) / 10
|
||
|
|
}
|