almost all ready

This commit is contained in:
2026-05-14 01:48:15 +02:00
parent 1b53c1c199
commit d55b0e2914
29 changed files with 5489 additions and 650 deletions
+478 -24
View File
@@ -15,6 +15,7 @@ type ProductPageRequest struct {
Slug string
LanguageID int64
ShopID int64
CurrencyID int64
}
type CategoryPageRequest struct {
@@ -22,6 +23,9 @@ type CategoryPageRequest struct {
Slug string
LanguageID int64
ShopID int64
CurrencyID int64
Page int
PerPage int
}
type ProductPageData struct {
@@ -31,10 +35,55 @@ type ProductPageData struct {
ShortDescription string
Description string
Price float64
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
TaxRate float64 `gorm:"column:tax_rate"`
CurrencyID int64 `gorm:"column:currency_id"`
CurrencyCode string `gorm:"column:currency_code"`
CurrencySign string `gorm:"column:currency_sign"`
ConversionRate float64 `gorm:"column:conversion_rate"`
CoverImageID sql.NullInt64
ImageURL string `gorm:"-"`
GalleryImages []ProductImage `gorm:"-"`
CategoryID int64
CategorySlug string
CategoryName string
Features []ProductFeature `gorm:"-"`
Accessories []CategoryProductCard `gorm:"-"`
Combinations []ProductCombination `gorm:"-"`
DefaultAttribute int64 `gorm:"-"`
}
type ProductImage struct {
ID int64 `gorm:"column:id_image"`
Cover bool `gorm:"column:cover"`
Position int `gorm:"column:position"`
URL string `gorm:"-"`
ThumbURL string `gorm:"-"`
}
type ProductFeature struct {
ID int64 `gorm:"column:id_feature"`
Name string `gorm:"column:name"`
Value string `gorm:"column:value"`
}
type ProductCombination struct {
ID int64 `gorm:"column:id_product_attribute"`
Price float64 `gorm:"column:price"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
DefaultOn bool `gorm:"column:default_on"`
ImageID sql.NullInt64 `gorm:"-"`
ImageURL string `gorm:"-"`
ThumbURL string `gorm:"-"`
Attributes []ProductCombinationAttribute `gorm:"-"`
}
type ProductCombinationAttribute struct {
Group string
PublicName string
Value string
GroupType string
Color string
}
type CategoryPageData struct {
@@ -43,16 +92,33 @@ type CategoryPageData struct {
Slug string
Description string
Products []CategoryProductCard `gorm:"-"`
Pagination CategoryPagination `gorm:"-"`
}
type CategoryPagination struct {
Page int
PerPage int
TotalItems int64
TotalPages int
}
type CategoryProductCard struct {
ID int64
Name string
Slug string
URL string `gorm:"-"`
Price float64
Description string
EAN13 string
ID int64
Name string
Slug string
CategorySlug string `gorm:"column:category_slug"`
URL string `gorm:"-"`
ImageURL string `gorm:"-"`
Price float64
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
TaxRate float64 `gorm:"column:tax_rate"`
CurrencyID int64 `gorm:"column:currency_id"`
CurrencyCode string `gorm:"column:currency_code"`
CurrencySign string `gorm:"column:currency_sign"`
ConversionRate float64 `gorm:"column:conversion_rate"`
CoverImageID sql.NullInt64 `gorm:"column:cover_image_id"`
ShortDescription string `gorm:"column:short_description"`
EAN13 string
}
type MenuItem struct {
@@ -92,54 +158,85 @@ func NewService(db *gorm.DB, prefix string) *Service {
func (s *Service) GetProductPage(ctx context.Context, req ProductPageRequest) (*ProductPageData, error) {
var product ProductPageData
if req.CurrencyID == 0 {
req.CurrencyID = 1
}
queryByID := fmt.Sprintf(`
SELECT p.id_product AS id,
pl.name AS name,
pl.link_rewrite AS slug,
pl.description_short AS short_description,
pl.description AS description,
ps.price AS price,
(ps.price * curr.conversion_rate) AS price,
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
curr.id_currency AS currency_id,
curr.iso_code AS currency_code,
curr.sign AS currency_sign,
curr.conversion_rate AS conversion_rate,
i.id_image AS cover_image_id,
p.id_category_default AS category_id,
cl.link_rewrite AS category_slug,
cl.name AS category_name
FROM %sproduct p
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
LEFT JOIN (
SELECT tr.id_tax_rules_group,
SUM(t.rate) AS tax_rate
FROM %stax_rule tr
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
GROUP BY tr.id_tax_rules_group
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
WHERE p.id_product = ?
AND ps.active = 1
AND pl.id_lang = ?
AND ps.id_shop = ?
LIMIT 1
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
queryBySlug := fmt.Sprintf(`
SELECT p.id_product AS id,
pl.name AS name,
pl.link_rewrite AS slug,
pl.description_short AS short_description,
pl.description AS description,
ps.price AS price,
(ps.price * curr.conversion_rate) AS price,
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
curr.id_currency AS currency_id,
curr.iso_code AS currency_code,
curr.sign AS currency_sign,
curr.conversion_rate AS conversion_rate,
i.id_image AS cover_image_id,
p.id_category_default AS category_id,
cl.link_rewrite AS category_slug,
cl.name AS category_name
FROM %sproduct p
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
LEFT JOIN (
SELECT tr.id_tax_rules_group,
SUM(t.rate) AS tax_rate
FROM %stax_rule tr
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
GROUP BY tr.id_tax_rules_group
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
WHERE pl.link_rewrite = ?
AND ps.active = 1
AND pl.id_lang = ?
AND ps.id_shop = ?
LIMIT 1
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
var result *gorm.DB
if req.ID != 0 {
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ID, req.LanguageID, req.ShopID).Scan(&product)
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryByID), req.ShopID, req.CurrencyID, req.ID, req.LanguageID).Scan(&product)
} else {
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.Slug, req.LanguageID, req.ShopID).Scan(&product)
result = s.db.WithContext(ctx).Raw(strings.TrimSpace(queryBySlug), req.ShopID, req.CurrencyID, req.Slug, req.LanguageID).Scan(&product)
}
if result.Error != nil {
return nil, result.Error
@@ -147,12 +244,316 @@ LIMIT 1
if result.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
features, err := s.loadProductFeatures(ctx, product.ID, req.LanguageID, req.ShopID)
if err != nil {
return nil, err
}
product.Features = features
images, err := s.loadProductImages(ctx, product.ID)
if err != nil {
return nil, err
}
product.GalleryImages = images
accessories, err := s.loadProductAccessories(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
if err != nil {
return nil, err
}
product.Accessories = accessories
combinations, err := s.loadProductCombinations(ctx, product.ID, req.LanguageID, req.ShopID, req.CurrencyID)
if err != nil {
return nil, err
}
product.Combinations = combinations
for _, combination := range combinations {
if combination.DefaultOn {
product.DefaultAttribute = combination.ID
break
}
}
if product.DefaultAttribute == 0 && len(combinations) > 0 {
product.DefaultAttribute = combinations[0].ID
}
return &product, nil
}
func (s *Service) loadProductImages(ctx context.Context, productID int64) ([]ProductImage, error) {
if productID == 0 {
return nil, nil
}
query := fmt.Sprintf(`
SELECT i.id_image,
CASE WHEN COALESCE(i.cover, 0) = 1 THEN 1 ELSE 0 END AS cover,
COALESCE(i.position, 0) AS position
FROM %simage i
WHERE i.id_product = ?
ORDER BY CASE WHEN COALESCE(i.cover, 0) = 1 THEN 0 ELSE 1 END,
COALESCE(i.position, 0) ASC,
i.id_image ASC
`, s.prefix)
images := make([]ProductImage, 0)
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), productID).Scan(&images).Error; err != nil {
return nil, err
}
return images, nil
}
func (s *Service) loadProductAccessories(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]CategoryProductCard, error) {
if productID == 0 {
return nil, nil
}
query := fmt.Sprintf(`
SELECT p.id_product AS id,
pl.name AS name,
pl.link_rewrite AS slug,
p.ean13 AS ean13,
cl.link_rewrite AS category_slug,
(ps.price * curr.conversion_rate) AS price,
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
curr.id_currency AS currency_id,
curr.iso_code AS currency_code,
curr.sign AS currency_sign,
curr.conversion_rate AS conversion_rate,
i.id_image AS cover_image_id,
pl.description_short AS short_description
FROM %saccessory a
JOIN %sproduct p ON p.id_product = a.id_product_2
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
LEFT JOIN %scategory_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = pl.id_lang
LEFT JOIN (
SELECT tr.id_tax_rules_group,
SUM(t.rate) AS tax_rate
FROM %stax_rule tr
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
GROUP BY tr.id_tax_rules_group
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
WHERE a.id_product_1 = ?
AND ps.active = 1
AND pl.id_lang = ?
ORDER BY p.id_product ASC
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
products := make([]CategoryProductCard, 0)
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), shopID, currencyID, productID, languageID).Scan(&products).Error; err != nil {
return nil, err
}
return products, nil
}
func (s *Service) loadProductFeatures(ctx context.Context, productID, languageID, shopID int64) ([]ProductFeature, error) {
if productID == 0 {
return nil, nil
}
query := fmt.Sprintf(`
SELECT pf.id_feature,
fl.name AS name,
fvl.value AS value
FROM %sfeature_product pf
LEFT JOIN %sfeature_lang fl
ON fl.id_feature = pf.id_feature
AND fl.id_lang = ?
LEFT JOIN %sfeature_value_lang fvl
ON fvl.id_feature_value = pf.id_feature_value
AND fvl.id_lang = ?
LEFT JOIN %sfeature f
ON f.id_feature = pf.id_feature
LEFT JOIN %sfeature_shop fs
ON fs.id_feature = f.id_feature
AND fs.id_shop = ?
WHERE pf.id_product = ?
ORDER BY f.position ASC
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
features := make([]ProductFeature, 0)
if err := s.db.WithContext(ctx).Raw(
strings.TrimSpace(query),
languageID,
languageID,
shopID,
productID,
).Scan(&features).Error; err != nil {
return nil, err
}
return features, nil
}
func (s *Service) loadProductCombinations(ctx context.Context, productID, languageID, shopID, currencyID int64) ([]ProductCombination, error) {
if productID == 0 {
return nil, nil
}
type combinationRow struct {
ID int64 `gorm:"column:id_product_attribute"`
Price float64 `gorm:"column:price"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl"`
DefaultOn bool `gorm:"column:default_on"`
GroupName string `gorm:"column:group_name"`
PublicName string `gorm:"column:public_group_name"`
Attribute string `gorm:"column:attribute_name"`
GroupType string `gorm:"column:group_type"`
Color string `gorm:"column:attribute_color"`
}
query := fmt.Sprintf(`
SELECT pa.id_product_attribute,
((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) AS price,
(((ps.price + COALESCE(pas.price, 0)) * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 1 ELSE 0 END AS default_on,
agl.name AS group_name,
agl.public_name AS public_group_name,
al.name AS attribute_name,
ag.group_type AS group_type,
a.color AS attribute_color
FROM %sproduct_attribute pa
JOIN %sproduct_shop ps
ON ps.id_product = pa.id_product
AND ps.id_shop = ?
JOIN %scurrency curr
ON curr.id_currency = ?
AND curr.deleted = 0
LEFT JOIN (
SELECT tr.id_tax_rules_group,
SUM(t.rate) AS tax_rate
FROM %stax_rule tr
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
GROUP BY tr.id_tax_rules_group
) tax_data
ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
LEFT JOIN %sproduct_attribute_shop pas
ON pas.id_product_attribute = pa.id_product_attribute
AND pas.id_shop = ?
JOIN %sproduct_attribute_combination pac
ON pac.id_product_attribute = pa.id_product_attribute
JOIN %sattribute a
ON a.id_attribute = pac.id_attribute
JOIN %sattribute_lang al
ON al.id_attribute = a.id_attribute
AND al.id_lang = ?
JOIN %sattribute_group ag
ON ag.id_attribute_group = a.id_attribute_group
JOIN %sattribute_group_lang agl
ON agl.id_attribute_group = ag.id_attribute_group
AND agl.id_lang = ?
WHERE pa.id_product = ?
ORDER BY CASE WHEN COALESCE(pas.default_on, pa.default_on, 0) = 1 THEN 0 ELSE 1 END,
pa.id_product_attribute ASC,
ag.position ASC,
a.position ASC,
al.name ASC
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
rows := make([]combinationRow, 0)
if err := s.db.WithContext(ctx).Raw(
strings.TrimSpace(query),
shopID,
currencyID,
shopID,
languageID,
languageID,
productID,
).Scan(&rows).Error; err != nil {
return nil, err
}
combinations := make([]ProductCombination, 0)
indexByID := make(map[int64]int)
for _, row := range rows {
idx, exists := indexByID[row.ID]
if !exists {
combinations = append(combinations, ProductCombination{
ID: row.ID,
Price: row.Price,
PriceTaxIncl: row.PriceTaxIncl,
DefaultOn: row.DefaultOn,
})
idx = len(combinations) - 1
indexByID[row.ID] = idx
}
if strings.TrimSpace(row.GroupName) != "" || strings.TrimSpace(row.Attribute) != "" {
combinations[idx].Attributes = append(combinations[idx].Attributes, ProductCombinationAttribute{
Group: row.GroupName,
PublicName: row.PublicName,
Value: row.Attribute,
GroupType: row.GroupType,
Color: row.Color,
})
}
}
if err := s.loadCombinationImageIDs(ctx, &combinations); err != nil {
return nil, err
}
return combinations, nil
}
func (s *Service) loadCombinationImageIDs(ctx context.Context, combinations *[]ProductCombination) error {
if combinations == nil || len(*combinations) == 0 {
return nil
}
combinationIDs := make([]int64, 0, len(*combinations))
indexByID := make(map[int64]int, len(*combinations))
for i, combination := range *combinations {
if combination.ID == 0 {
continue
}
combinationIDs = append(combinationIDs, combination.ID)
indexByID[combination.ID] = i
}
if len(combinationIDs) == 0 {
return nil
}
type combinationImageRow struct {
ID int64 `gorm:"column:id_product_attribute"`
ImageID int64 `gorm:"column:id_image"`
}
query := fmt.Sprintf(`
SELECT pai.id_product_attribute,
MIN(pai.id_image) AS id_image
FROM %sproduct_attribute_image pai
WHERE pai.id_product_attribute IN ?
GROUP BY pai.id_product_attribute
`, s.prefix)
rows := make([]combinationImageRow, 0)
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(query), combinationIDs).Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
idx, exists := indexByID[row.ID]
if !exists || row.ImageID == 0 {
continue
}
(*combinations)[idx].ImageID = sql.NullInt64{Int64: row.ImageID, Valid: true}
}
return nil
}
func (s *Service) GetCategoryPage(ctx context.Context, req CategoryPageRequest) (*CategoryPageData, error) {
var category CategoryPageData
if req.CurrencyID == 0 {
req.CurrencyID = 1
}
if req.Page <= 0 {
req.Page = 1
}
if req.PerPage <= 0 {
req.PerPage = 20
}
categoryQuery := fmt.Sprintf(`
SELECT c.id_category AS id,
cl.name AS name,
@@ -213,25 +614,67 @@ LIMIT 1
}
}
countQuery := fmt.Sprintf(`
SELECT COUNT(*) AS total_items
FROM %scategory_product cp
JOIN %sproduct p ON p.id_product = cp.id_product
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
WHERE cp.id_category = ?
AND ps.active = 1
AND pl.id_lang = ?
`, s.prefix, s.prefix, s.prefix, s.prefix)
var countRow struct {
TotalItems int64 `gorm:"column:total_items"`
}
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(countQuery), req.ShopID, category.ID, req.LanguageID).Scan(&countRow).Error; err != nil {
return nil, err
}
category.Pagination = CategoryPagination{
Page: req.Page,
PerPage: req.PerPage,
TotalItems: countRow.TotalItems,
TotalPages: totalPages(countRow.TotalItems, req.PerPage),
}
offset := (req.Page - 1) * req.PerPage
productQuery := fmt.Sprintf(`
SELECT p.id_product AS id,
pl.name AS name,
pl.link_rewrite AS slug,
p.ean13 AS ean13,
ps.price AS price,
pl.description_short AS description
(ps.price * curr.conversion_rate) AS price,
((ps.price * curr.conversion_rate) * (1 + COALESCE(tax_data.tax_rate, 0) / 100)) AS price_tax_incl,
COALESCE(tax_data.tax_rate, 0) AS tax_rate,
curr.id_currency AS currency_id,
curr.iso_code AS currency_code,
curr.sign AS currency_sign,
curr.conversion_rate AS conversion_rate,
i.id_image AS cover_image_id,
pl.description_short AS short_description
FROM %scategory_product cp
JOIN %sproduct p ON p.id_product = cp.id_product
JOIN %sproduct_lang pl ON pl.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product
JOIN %sproduct_shop ps ON ps.id_product = p.id_product AND ps.id_shop = ?
JOIN %scurrency curr ON curr.id_currency = ? AND curr.deleted = 0
LEFT JOIN %simage i ON i.id_product = p.id_product AND i.cover = 1
LEFT JOIN (
SELECT tr.id_tax_rules_group,
SUM(t.rate) AS tax_rate
FROM %stax_rule tr
JOIN %stax t ON t.id_tax = tr.id_tax AND t.active = 1
GROUP BY tr.id_tax_rules_group
) tax_data ON tax_data.id_tax_rules_group = ps.id_tax_rules_group
WHERE cp.id_category = ?
AND ps.active = 1
AND pl.id_lang = ?
AND ps.id_shop = ?
ORDER BY cp.position ASC, p.id_product ASC
LIMIT 48
`, s.prefix, s.prefix, s.prefix, s.prefix)
LIMIT ?
OFFSET ?
`, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix, s.prefix)
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), category.ID, req.LanguageID, req.ShopID).Scan(&category.Products).Error; err != nil {
if err := s.db.WithContext(ctx).Raw(strings.TrimSpace(productQuery), req.ShopID, req.CurrencyID, category.ID, req.LanguageID, req.PerPage, offset).Scan(&category.Products).Error; err != nil {
return nil, err
}
@@ -266,6 +709,17 @@ func (s *Service) ResolveLanguageID(ctx context.Context, req *http.Request, fall
return row.ID
}
func totalPages(totalItems int64, perPage int) int {
if totalItems <= 0 || perPage <= 0 {
return 0
}
pages := int(totalItems / int64(perPage))
if totalItems%int64(perPage) != 0 {
pages++
}
return pages
}
func (s *Service) GetCategoryMenu(ctx context.Context, languageID int64, shopID int64) ([]MenuItem, error) {
rootCategoryID, err := s.rootCategoryID(ctx)
if err != nil {