381 lines
11 KiB
Go
381 lines
11 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// V1ProcessItem is the legacy public-API product payload (POST /products/process items[]).
|
|
type V1ProcessItem struct {
|
|
EAN string `json:"ean"`
|
|
CategoryUniqueID string `json:"category_unique_id,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Specifications []map[string]any `json:"specifications,omitempty"`
|
|
Search string `json:"search,omitempty"`
|
|
MainImage string `json:"main_image,omitempty"`
|
|
MoreImages any `json:"more_images,omitempty"`
|
|
MainImageCamel string `json:"mainImage,omitempty"`
|
|
MoreImagesCamel any `json:"moreImages,omitempty"`
|
|
ImageURL string `json:"image_url,omitempty"`
|
|
AdditionalImageURLs any `json:"additional_image_urls,omitempty"`
|
|
ImageLink string `json:"image_link,omitempty"`
|
|
AdditionalImageLink any `json:"additional_image_link,omitempty"`
|
|
}
|
|
|
|
var nonDigit = regexp.MustCompile(`[^0-9]`)
|
|
|
|
// NormalizeGTIN keeps digits when present; otherwise returns the trimmed original.
|
|
func NormalizeGTIN(ean string) string {
|
|
trimmed := strings.TrimSpace(ean)
|
|
digits := nonDigit.ReplaceAllString(trimmed, "")
|
|
if digits != "" {
|
|
return digits
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// BuildMappedDataFromV1Item mirrors legacy buildMappedDataFromItem + image field storage.
|
|
// Empty optional fields are omitted (not null) so jsonb || merges cannot wipe catalog titles.
|
|
func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
|
|
mapped := map[string]any{
|
|
"ean": item.EAN,
|
|
}
|
|
if item.Title != "" {
|
|
mapped["title"] = item.Title
|
|
mapped["name"] = item.Title
|
|
}
|
|
if item.Description != "" {
|
|
mapped["description"] = item.Description
|
|
}
|
|
if len(item.Specifications) > 0 {
|
|
mapped["specifications"] = item.Specifications
|
|
}
|
|
if item.Search != "" {
|
|
mapped["search"] = item.Search
|
|
}
|
|
if item.CategoryUniqueID != "" {
|
|
mapped["category"] = item.CategoryUniqueID
|
|
mapped["category_unique_id"] = item.CategoryUniqueID
|
|
}
|
|
for k, v := range mappedImageFieldsFromV1Item(item) {
|
|
mapped[k] = v
|
|
}
|
|
return mapped
|
|
}
|
|
|
|
// v1ItemHasContent reports whether the request carries feed/product fields beyond EAN.
|
|
// EAN-only requests must resolve an existing catalog row (e.g. after admin clone).
|
|
func v1ItemHasContent(item V1ProcessItem) bool {
|
|
if strings.TrimSpace(item.Title) != "" || strings.TrimSpace(item.Description) != "" {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(item.CategoryUniqueID) != "" || strings.TrimSpace(item.Search) != "" {
|
|
return true
|
|
}
|
|
if len(item.Specifications) > 0 {
|
|
return true
|
|
}
|
|
return len(mappedImageFieldsFromV1Item(item)) > 0
|
|
}
|
|
|
|
func mappedImageFieldsFromV1Item(item V1ProcessItem) map[string]any {
|
|
source := map[string]any{}
|
|
putIf := func(k, v string) {
|
|
if strings.TrimSpace(v) != "" {
|
|
source[k] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
putIf("main_image", item.MainImage)
|
|
putIf("mainImage", item.MainImageCamel)
|
|
putIf("image_url", item.ImageURL)
|
|
putIf("image_link", item.ImageLink)
|
|
if item.MoreImages != nil {
|
|
source["more_images"] = item.MoreImages
|
|
}
|
|
if item.MoreImagesCamel != nil {
|
|
source["moreImages"] = item.MoreImagesCamel
|
|
}
|
|
if item.AdditionalImageURLs != nil {
|
|
source["additional_image_urls"] = item.AdditionalImageURLs
|
|
}
|
|
if item.AdditionalImageLink != nil {
|
|
source["additional_image_link"] = item.AdditionalImageLink
|
|
}
|
|
return MappedImageFieldsForStorage(source)
|
|
}
|
|
|
|
// MappedImageFieldsForStorage writes feed-compatible image keys onto mapped_data.
|
|
func MappedImageFieldsForStorage(source map[string]any) map[string]any {
|
|
main, more := ExtractProductImages(source, nil)
|
|
out := map[string]any{}
|
|
if main != "" {
|
|
out["image_url"] = main
|
|
out["main_image"] = main
|
|
out["image_link"] = main
|
|
}
|
|
if len(more) > 0 {
|
|
out["additional_image_urls"] = more
|
|
if len(more) == 1 {
|
|
out["additional_image_link"] = more[0]
|
|
}
|
|
images := make([]string, 0, 1+len(more))
|
|
if main != "" {
|
|
images = append(images, main)
|
|
}
|
|
images = append(images, more...)
|
|
out["images"] = images
|
|
} else if main != "" {
|
|
out["images"] = []string{main}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ExtractProductImages returns main_image + more_images from mapped/raw maps.
|
|
func ExtractProductImages(mapped, raw map[string]any) (main string, more []string) {
|
|
merged := map[string]any{}
|
|
for k, v := range raw {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range mapped {
|
|
merged[k] = v
|
|
}
|
|
mainKeys := []string{"image_url", "main_image", "image_link", "mainImage", "MainImage", "imageUrl", "imageLink", "ImageLink"}
|
|
moreKeys := []string{"additional_image_urls", "additional_image_link", "more_images", "moreImages", "MoreImages", "moreimages", "additionalImageLink", "additionalImageUrls"}
|
|
for _, k := range mainKeys {
|
|
if u := coerceToURLString(merged[k]); u != "" {
|
|
main = u
|
|
break
|
|
}
|
|
}
|
|
for _, k := range moreKeys {
|
|
list := coerceToURLList(merged[k])
|
|
if len(list) > 0 {
|
|
more = list
|
|
break
|
|
}
|
|
}
|
|
if imgs, ok := merged["images"].([]any); ok && len(imgs) > 0 {
|
|
urls := coerceToURLList(imgs)
|
|
if main == "" && len(urls) > 0 {
|
|
main = urls[0]
|
|
urls = urls[1:]
|
|
} else if len(urls) > 0 && urls[0] == main {
|
|
urls = urls[1:]
|
|
}
|
|
for _, u := range urls {
|
|
if u != main && !containsString(more, u) {
|
|
more = append(more, u)
|
|
}
|
|
}
|
|
}
|
|
if main != "" {
|
|
filtered := more[:0]
|
|
for _, u := range more {
|
|
if u != main {
|
|
filtered = append(filtered, u)
|
|
}
|
|
}
|
|
more = filtered
|
|
}
|
|
return main, more
|
|
}
|
|
|
|
func coerceToURLString(value any) string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
trimmed := strings.TrimSpace(v)
|
|
if trimmed == "" || trimmed == "[object Object]" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(trimmed, "//") {
|
|
return "https:" + trimmed
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(trimmed), "http://") || strings.HasPrefix(strings.ToLower(trimmed), "https://") {
|
|
return trimmed
|
|
}
|
|
return ""
|
|
case []any:
|
|
for _, entry := range v {
|
|
if u := coerceToURLString(entry); u != "" {
|
|
return u
|
|
}
|
|
}
|
|
return ""
|
|
case map[string]any:
|
|
for _, k := range []string{"#text", "__cdata", "@_href", "@_url", "href", "url"} {
|
|
if u := coerceToURLString(v[k]); u != "" {
|
|
return u
|
|
}
|
|
}
|
|
return ""
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func coerceToURLList(value any) []string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return nil
|
|
case string:
|
|
trimmed := strings.TrimSpace(v)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
if strings.Contains(trimmed, ",") {
|
|
parts := strings.Split(trimmed, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if u := coerceToURLString(p); u != "" {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
if u := coerceToURLString(trimmed); u != "" {
|
|
return []string{u}
|
|
}
|
|
return nil
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, entry := range v {
|
|
if u := coerceToURLString(entry); u != "" {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
case []string:
|
|
out := make([]string, 0, len(v))
|
|
for _, entry := range v {
|
|
if u := coerceToURLString(entry); u != "" {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
if u := coerceToURLString(value); u != "" {
|
|
return []string{u}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func containsString(ss []string, want string) bool {
|
|
for _, s := range ss {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// EnsureRawResult is one resolved raw product from a legacy items[] entry.
|
|
type EnsureRawResult struct {
|
|
RawProductID uuid.UUID
|
|
EAN string
|
|
Created bool
|
|
}
|
|
|
|
// EnsureRawProductsFromV1Items finds or creates/updates raw_products by EAN for the company.
|
|
// Returns successfully resolved IDs and per-item error messages (non-fatal for partial batches).
|
|
//
|
|
// EAN-only items reuse existing catalog mapped_data (e.g. after admin clone-from-company).
|
|
// Creating a brand-new row requires at least one content field; otherwise callers get empty
|
|
// "Product {ean}" stubs with no feed data.
|
|
func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uuid.UUID, items []V1ProcessItem) (ids []uuid.UUID, results []EnsureRawResult, errs []string, err error) {
|
|
if s == nil || s.Pool == nil {
|
|
return nil, nil, nil, fmt.Errorf("catalog not configured")
|
|
}
|
|
ids = make([]uuid.UUID, 0, len(items))
|
|
results = make([]EnsureRawResult, 0, len(items))
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.EAN) == "" {
|
|
errs = append(errs, "All items must have a valid 'ean' field")
|
|
continue
|
|
}
|
|
gtin := NormalizeGTIN(item.EAN)
|
|
mapped := BuildMappedDataFromV1Item(item)
|
|
mappedJSON, mErr := json.Marshal(mapped)
|
|
if mErr != nil {
|
|
errs = append(errs, fmt.Sprintf("Error processing item with EAN %s: %v", item.EAN, mErr))
|
|
continue
|
|
}
|
|
|
|
var existingID uuid.UUID
|
|
var existingMapped []byte
|
|
qErr := s.Pool.QueryRow(ctx, `
|
|
SELECT id, mapped_data
|
|
FROM raw_products
|
|
WHERE company_id = $1 AND gtin = $2
|
|
ORDER BY updated_at DESC NULLS LAST
|
|
LIMIT 1`, companyID, gtin).Scan(&existingID, &existingMapped)
|
|
if qErr == nil {
|
|
merged := map[string]any{}
|
|
_ = json.Unmarshal(existingMapped, &merged)
|
|
changed := false
|
|
for k, v := range mapped {
|
|
if v == nil {
|
|
continue
|
|
}
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
|
continue
|
|
}
|
|
merged[k] = v
|
|
changed = true
|
|
}
|
|
if item.CategoryUniqueID != "" {
|
|
merged["category"] = item.CategoryUniqueID
|
|
merged["category_unique_id"] = item.CategoryUniqueID
|
|
changed = true
|
|
}
|
|
if changed {
|
|
mergedJSON, _ := json.Marshal(merged)
|
|
_, _ = s.Pool.Exec(ctx, `
|
|
UPDATE raw_products
|
|
SET mapped_data = $3::jsonb, updated_at = now()
|
|
WHERE id = $1 AND company_id = $2`, existingID, companyID, string(mergedJSON))
|
|
}
|
|
ids = append(ids, existingID)
|
|
results = append(results, EnsureRawResult{RawProductID: existingID, EAN: item.EAN, Created: false})
|
|
continue
|
|
}
|
|
if qErr != nil && qErr != pgx.ErrNoRows {
|
|
errs = append(errs, fmt.Sprintf("Error processing item with EAN %s: %v", item.EAN, qErr))
|
|
continue
|
|
}
|
|
|
|
if !v1ItemHasContent(item) {
|
|
errs = append(errs, fmt.Sprintf(
|
|
"No catalog product for EAN %s. Use a GTIN from the cloned/synced feed, or include title/description/images in the request.",
|
|
item.EAN,
|
|
))
|
|
continue
|
|
}
|
|
|
|
var newID uuid.UUID
|
|
insErr := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed)
|
|
VALUES ($1, $2, $3::jsonb, $3::jsonb, 'unprocessed', false)
|
|
ON CONFLICT (company_id, gtin) DO UPDATE
|
|
SET mapped_data = raw_products.mapped_data || EXCLUDED.mapped_data,
|
|
updated_at = now()
|
|
RETURNING id`, companyID, gtin, string(mappedJSON)).Scan(&newID)
|
|
if insErr != nil {
|
|
errs = append(errs, fmt.Sprintf("Failed to create raw product for EAN: %s", item.EAN))
|
|
continue
|
|
}
|
|
ids = append(ids, newID)
|
|
results = append(results, EnsureRawResult{RawProductID: newID, EAN: item.EAN, Created: true})
|
|
}
|
|
return ids, results, errs, nil
|
|
}
|