FloorVisualizer/internal/repository/product_repo.go

305 lines
8.7 KiB
Go
Raw Normal View History

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, is_official_price,
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, is_official_price,
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,$23)
`, 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.IsOfficialPrice,
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) {
rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku)
if err != nil {
return nil, err
}
defer rows.Close()
prods, err := ScanProducts(rows)
if err != nil {
return nil, err
}
if len(prods) == 0 {
return nil, sql.ErrNoRows
}
return &prods[0], 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 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.IsOfficialPrice,
&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()
}