- 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>
273 lines
8.4 KiB
Go
273 lines
8.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"floorvisualizer/internal/model"
|
|
)
|
|
|
|
const selectCols = `brand, group_name, sku, series_name, style_name,
|
|
category, material, color_tone, finish,
|
|
price_per_sqft, price_tier, price_source,
|
|
main_image_url, room_image_url, source_url, description, status,
|
|
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
|
|
size_label, COALESCE(width_in,0), COALESCE(length_in,0)`
|
|
|
|
const insertCols = `brand, group_name, sku, series_name, style_name,
|
|
category, material, color_tone, finish,
|
|
price_per_sqft, price_tier, price_source,
|
|
main_image_url, room_image_url, source_url, description, status,
|
|
coverage_sqft_per_box, wood_species,
|
|
size_label, width_in, length_in`
|
|
|
|
|
|
// ── Import ──────────────────────────────────────────────────
|
|
|
|
func InsertProducts(d *sql.DB, products []model.Product) error {
|
|
for _, p := range products {
|
|
_, err := d.Exec(`
|
|
INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
|
`, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName,
|
|
p.Category, p.Material, p.ColorTone, p.Finish,
|
|
p.PricePerSqft, p.PriceTier, p.PriceSource,
|
|
p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status,
|
|
p.CoverageSqftPerBox, p.WoodSpecies,
|
|
p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn))
|
|
if err != nil {
|
|
return fmt.Errorf("insert %s: %w", p.SKU, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nf(v float64) interface{} {
|
|
if v == 0 { return nil }
|
|
return v
|
|
}
|
|
|
|
// ── Query types ─────────────────────────────────────────────
|
|
|
|
type FilterParams struct {
|
|
SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string
|
|
Page, Limit int
|
|
}
|
|
|
|
type FilterOptions struct {
|
|
Categories []string `json:"categories"`
|
|
SeriesNames []string `json:"series_names"`
|
|
Brands []model.BrandInfo `json:"brands"`
|
|
}
|
|
|
|
// ── Queries ─────────────────────────────────────────────────
|
|
|
|
func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
|
|
prods, _, err := QueryFilteredProducts(d, FilterParams{Limit: 10000})
|
|
return prods, err
|
|
}
|
|
|
|
func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) {
|
|
if p.Limit <= 0 { p.Limit = 20 }
|
|
if p.Page <= 0 { p.Page = 1 }
|
|
|
|
var conds []string
|
|
var args []interface{}
|
|
i := 1
|
|
|
|
add := func(clause string, val string) {
|
|
if val != "" {
|
|
conds = append(conds, fmt.Sprintf(clause, i))
|
|
args = append(args, val)
|
|
i++
|
|
}
|
|
}
|
|
add("category = $%d", p.Category)
|
|
add("series_name = $%d", p.SeriesName)
|
|
add("brand = $%d", p.Brand)
|
|
add("material = $%d", p.Material)
|
|
add("color_tone = $%d", p.ColorTone)
|
|
add("finish = $%d", p.Finish)
|
|
add("wood_species = $%d", p.WoodSpecies)
|
|
|
|
if p.Search != "" {
|
|
pat := "%" + p.Search + "%"
|
|
clauses := []string{
|
|
fmt.Sprintf("style_name ILIKE $%d", i),
|
|
fmt.Sprintf("sku ILIKE $%d", i+1),
|
|
fmt.Sprintf("brand ILIKE $%d", i+2),
|
|
}
|
|
conds = append(conds, "("+strings.Join(clauses, " OR ")+")")
|
|
for k := 0; k < 3; k++ { args = append(args, pat) }
|
|
i += 3
|
|
}
|
|
|
|
where := ""
|
|
if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") }
|
|
|
|
// Count distinct styles (dedup same brand+style_name across different sizes)
|
|
var total int
|
|
d.QueryRow("SELECT COUNT(*) FROM (SELECT DISTINCT brand, style_name FROM products "+where+") AS t", args...).Scan(&total)
|
|
|
|
offset := (p.Page - 1) * p.Limit
|
|
// Use DISTINCT ON to keep one row per brand+style_name, pick the first by id
|
|
query := fmt.Sprintf("SELECT DISTINCT ON (brand, style_name) "+selectCols+" FROM products %s ORDER BY brand, style_name, id LIMIT $%d OFFSET $%d", where, i, i+1)
|
|
args = append(args, p.Limit, offset)
|
|
|
|
rows, err := d.Query(query, args...)
|
|
if err != nil { return nil, 0, err }
|
|
defer rows.Close()
|
|
prods, err := scanProducts(rows)
|
|
return prods, total, err
|
|
}
|
|
|
|
func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) {
|
|
var p model.Product
|
|
if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// GetVariantsByStyle returns all products with the same brand+style_name but different SKU.
|
|
func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) {
|
|
rows, err := d.Query(`
|
|
SELECT `+selectCols+` FROM products
|
|
WHERE style_name = (SELECT style_name FROM products WHERE sku = $1)
|
|
AND brand = (SELECT brand FROM products WHERE sku = $1)
|
|
AND series_name = (SELECT series_name FROM products WHERE sku = $1)
|
|
AND sku != $1
|
|
ORDER BY width_in, length_in
|
|
`, sku)
|
|
if err != nil { return nil, err }
|
|
defer rows.Close()
|
|
return scanProducts(rows)
|
|
}
|
|
|
|
func VariantCount(d *sql.DB, sku string) int {
|
|
var n int
|
|
d.QueryRow(`SELECT COUNT(*) FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) AND sku != $1`, sku).Scan(&n)
|
|
return n
|
|
}
|
|
|
|
// GetSpecCounts returns (brand|style_name|series_name) → total variant count for a batch of SKUs.
|
|
func GetSpecCounts(d *sql.DB, skus []string) map[string]int {
|
|
result := map[string]int{}
|
|
if len(skus) == 0 {
|
|
return result
|
|
}
|
|
placeholders := make([]string, len(skus))
|
|
args := make([]interface{}, len(skus))
|
|
for i, sku := range skus {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
args[i] = sku
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT p.brand, p.style_name, p.series_name, COUNT(*) AS total
|
|
FROM products p
|
|
WHERE (p.brand, p.style_name, p.series_name) IN (
|
|
SELECT brand, style_name, series_name FROM products WHERE sku IN (%s)
|
|
)
|
|
GROUP BY p.brand, p.style_name, p.series_name
|
|
`, strings.Join(placeholders, ","))
|
|
rows, err := d.Query(query, args...)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var brand, style, series string
|
|
var total int
|
|
if err := rows.Scan(&brand, &style, &series, &total); err == nil {
|
|
result[brand+"|"+style+"|"+series] = total
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func GetFilterOptions(d *sql.DB) (*FilterOptions, error) {
|
|
opts := &FilterOptions{}
|
|
for _, q := range []struct{ query string; out *[]string }{
|
|
{"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories},
|
|
{"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames},
|
|
} {
|
|
rows, err := d.Query(q.query)
|
|
if err != nil { return nil, err }
|
|
for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) }
|
|
rows.Close()
|
|
}
|
|
opts.Brands = GetBrandInfos(d)
|
|
return opts, nil
|
|
}
|
|
|
|
// ── Brand info with stats ───────────────────────────────────
|
|
|
|
var (
|
|
brandLogos map[string]string
|
|
brandLogosOnce sync.Once
|
|
)
|
|
|
|
func loadBrandLogos() map[string]string {
|
|
brandLogosOnce.Do(func() {
|
|
brandLogos = map[string]string{}
|
|
data, err := os.ReadFile("brand_logos.json")
|
|
if err != nil {
|
|
return
|
|
}
|
|
json.Unmarshal(data, &brandLogos)
|
|
})
|
|
return brandLogos
|
|
}
|
|
|
|
// GetBrandInfos returns all brands with style count, category count, and logo URL.
|
|
func GetBrandInfos(d *sql.DB) []model.BrandInfo {
|
|
logos := loadBrandLogos()
|
|
|
|
rows, err := d.Query(`
|
|
SELECT brand,
|
|
COUNT(DISTINCT style_name) AS style_count,
|
|
COUNT(DISTINCT SPLIT_PART(series_name, ' | ', 1)) AS collection_count
|
|
FROM products
|
|
WHERE brand != ''
|
|
GROUP BY brand
|
|
ORDER BY brand
|
|
`)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.BrandInfo
|
|
for rows.Next() {
|
|
var bi model.BrandInfo
|
|
if err := rows.Scan(&bi.Name, &bi.StyleCount, &bi.CollectionCount); err != nil {
|
|
continue
|
|
}
|
|
if u, ok := logos[bi.Name]; ok {
|
|
bi.LogoURL = u
|
|
}
|
|
out = append(out, bi)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scanProducts(rows *sql.Rows) ([]model.Product, error) {
|
|
var out []model.Product
|
|
for rows.Next() {
|
|
var p model.Product
|
|
if err := rows.Scan(
|
|
&p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName,
|
|
&p.Category, &p.Material, &p.ColorTone, &p.Finish,
|
|
&p.PricePerSqft, &p.PriceTier, &p.PriceSource,
|
|
&p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status,
|
|
&p.CoverageSqftPerBox, &p.WoodSpecies,
|
|
&p.SizeLabel, &p.WidthIn, &p.LengthIn,
|
|
); err != nil { return nil, err }
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|