product-procedures #59

Merged
goc_daniel merged 4 commits from product-procedures into main 2026-04-13 13:32:28 +00:00
10 changed files with 736 additions and 45 deletions
Showing only changes of commit 75af44b0df - Show all commits

View File

@@ -6,6 +6,7 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/service/productService" "git.ma-al.com/goc_daniel/b2b/app/service/productService"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
"git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor" "git.ma-al.com/goc_daniel/b2b/app/utils/localeExtractor"
"git.ma-al.com/goc_daniel/b2b/app/utils/nullable" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable"
@@ -34,6 +35,7 @@ func ProductsHandlerRoutes(r fiber.Router) fiber.Router {
r.Get("/:id/:country_id/:quantity", handler.GetProductJson) r.Get("/:id/:country_id/:quantity", handler.GetProductJson)
r.Get("/list", handler.ListProducts) r.Get("/list", handler.ListProducts)
r.Get("/list-variants/:product_id", handler.ListProductVariants)
return r return r
} }
@@ -84,13 +86,13 @@ func (h *ProductsHandler) ListProducts(c fiber.Ctx) error {
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
} }
id_lang, ok := localeExtractor.GetLangID(c) customer, ok := localeExtractor.GetCustomer(c)
if !ok { if !ok || customer == nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
} }
list, err := h.productService.Find(id_lang, paging, filters) list, err := h.productService.Find(customer.LangID, paging, filters, customer, 1, constdata.SHOP_ID)
if err != nil { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)). return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
@@ -107,3 +109,27 @@ var columnMappingListProducts map[string]string = map[string]string{
"category_id": "cp.id_category", "category_id": "cp.id_category",
"quantity": "sa.quantity", "quantity": "sa.quantity",
} }
func (h *ProductsHandler) ListProductVariants(c fiber.Ctx) error {
productIDStr := c.Params("product_id")
productID, err := strconv.Atoi(productIDStr)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
customer, ok := localeExtractor.GetCustomer(c)
if !ok || customer == nil {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
}
list, err := h.productService.GetProductAttributes(customer.LangID, uint(productID), constdata.SHOP_ID, customer.ID, customer.CountryID, 1)
if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))
}
return c.JSON(response.Make(&list, len(list), i18n.T_(c, response.Message_OK)))
}

View File

@@ -1,15 +1,13 @@
package model package model
import "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
// Represents a country together with its associated currency // Represents a country together with its associated currency
type Country struct { type Country struct {
ID uint `gorm:"primaryKey;column:id" json:"id"` ID uint `gorm:"primaryKey;column:id" json:"id"`
Name string `gorm:"column:name" json:"name"` Name string `gorm:"column:name" json:"name"`
Flag string `gorm:"size:16;not null;column:flag" json:"flag"` Flag string `gorm:"size:16;not null;column:flag" json:"flag"`
PSCurrencyID uint `gorm:"column:currency_id" json:"currency_id"` CurrencyID uint `gorm:"column:b2b_id_currency" json:"currency_id"`
PSCurrency *dbmodel.PsCurrency `gorm:"foreignKey:PSCurrencyID;references:IDCurrency" json:"ps_currency"` Currency *Currency `gorm:"foreignKey:CurrencyID" json:"currency,omitempty"`
} }
func (Country) TableName() string { func (Country) TableName() string {

View File

@@ -29,6 +29,7 @@ type Customer struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language
CountryID uint `gorm:"default:2" json:"country_id"` // User's selected country CountryID uint `gorm:"default:2" json:"country_id"` // User's selected country
Country *Country `gorm:"foreignKey:CountryID" json:"country,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@@ -70,6 +70,8 @@ type ProductInList struct {
Reference string `gorm:"column:reference" json:"reference"` Reference string `gorm:"column:reference" json:"reference"`
VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"`
Quantity int64 `gorm:"column:quantity" json:"quantity"` Quantity int64 `gorm:"column:quantity" json:"quantity"`
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl" json:"price_tax_incl"`
} }
type ProductFilters struct { type ProductFilters struct {

View File

@@ -10,13 +10,15 @@ import (
"git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"git.ma-al.com/goc_daniel/b2b/app/view"
"git.ma-al.com/goc_marek/gormcol" "git.ma-al.com/goc_marek/gormcol"
"github.com/WinterYukky/gorm-extra-clause-plugin/exclause" "github.com/WinterYukky/gorm-extra-clause-plugin/exclause"
) )
type UIProductsRepo interface { type UIProductsRepo interface {
GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error)
Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error)
GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error)
} }
type ProductsRepo struct{} type ProductsRepo struct{}
@@ -46,10 +48,7 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo
return &raw, nil return &raw, nil
} }
func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { func (repo *ProductsRepo) Find(langID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) {
var list []model.ProductInList
var total int64
query := db.Get(). query := db.Get().
Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps").
Select(` Select(`
@@ -63,9 +62,9 @@ func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.Filter
sa.quantity AS quantity sa.quantity AS quantity
`, config.Get().Image.ImagePrefix). `, config.Get().Image.ImagePrefix).
Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product"). Joins("JOIN "+dbmodel.PsProductCols.IDProduct.Tab()+" p ON p.id_product = ps.id_product").
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", langID).
Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1").
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", langID).
Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product"). Joins("JOIN ps_category_product cp ON cp.id_product = ps.id_product").
Joins("LEFT JOIN variants v ON v.id_product = ps.id_product"). Joins("LEFT JOIN variants v ON v.id_product = ps.id_product").
Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0"). Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product AND sa.id_product_attribute = 0").
@@ -79,27 +78,62 @@ func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.Filter
}}). }}).
Order("ps.id_product DESC") Order("ps.id_product DESC")
// Apply all filters query = query.Scopes(filt.All()...)
if filt != nil {
filt.ApplyAll(query)
}
// run counter first as query is without limit and offset list, err := find.Paginate[model.ProductInList](langID, p, query)
err := query.Count(&total).Error
if err != nil { if err != nil {
return find.Found[model.ProductInList]{}, err return nil, err
}
return &list, nil
} }
err = query. func (repo *ProductsRepo) GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error) {
Limit(p.Limit()). var result []view.ProductAttribute
Offset(p.Offset()). err := db.DB.
Find(&list).Error Raw(`
CALL get_product_attributes_with_price(?, ?, ?, ?, ?, ?)
`,
langID,
productID,
shopID,
customerID,
countryID,
quantity,
).
Scan(&result).Error
if err != nil { if err != nil {
return find.Found[model.ProductInList]{}, err return nil, err
} }
return find.Found[model.ProductInList]{ return result, nil
Items: list, }
Count: uint(total),
}, nil func (repo *ProductsRepo) PopulateProductPrice(product *model.ProductInList, targetCustomer *model.Customer, quantity int, shopID uint) error {
row := db.Get().Raw(
"CALL get_product_price(?, ?, ?, ?, ?)",
product.ProductID,
shopID,
targetCustomer.ID,
targetCustomer.CountryID,
quantity,
).Row()
var (
id uint
base float64
excl float64
incl float64
tax float64
)
err := row.Scan(&id, &base, &excl, &incl, &tax)
if err != nil {
return err
}
product.PriceTaxExcl = excl
product.PriceTaxIncl = incl
return nil
} }

View File

@@ -21,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// JWTClaims represents the JWT claims // JWTClaims represents the JWT claims
@@ -436,7 +437,7 @@ func (s *AuthService) RevokeAllRefreshTokens(userID uint) {
// GetUserByID retrieves a user by ID // GetUserByID retrieves a user by ID
func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) { func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
var user model.Customer var user model.Customer
if err := s.db.Preload("Role.Permissions").First(&user, userID).Error; err != nil { if err := s.db.Preload("Role.Permissions").Preload(clause.Associations).First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, responseErrors.ErrUserNotFound return nil, responseErrors.ErrUserNotFound
} }

View File

@@ -2,12 +2,15 @@ package productService
import ( import (
"encoding/json" "encoding/json"
"errors"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
"git.ma-al.com/goc_daniel/b2b/app/view"
) )
type ProductService struct { type ProductService struct {
@@ -29,6 +32,64 @@ func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_
return products, nil return products, nil
} }
func (s *ProductService) Find(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { func (s *ProductService) Find(id_lang uint, p find.Paging, filters *filters.FiltersList, customer *model.Customer, quantity int, shopID uint) (*find.Found[model.ProductInList], error) {
return s.productsRepo.Find(id_lang, p, filters) if customer == nil || customer.Country == nil {
return nil, errors.New("customer is nil or is missing fields")
}
// customer.ID, customer.CountryID, uint(customer.Country.CurrencyID), quantity, shopID
found, err := s.productsRepo.Find(id_lang, p, filters)
if err != nil {
return nil, err
}
for i := range found.Items {
if found.Items[i].VariantsNumber <= 0 {
err := s.PopulateProductPrice(&found.Items[i], customer, quantity, shopID)
if err != nil {
return nil, err
}
}
}
return found, err
}
func (s *ProductService) PopulateProductPrice(product *model.ProductInList, targetCustomer *model.Customer, quantity int, shopID uint) error {
row := db.Get().Raw(
"CALL get_product_price(?, ?, ?, ?, ?)",
product.ProductID,
shopID,
targetCustomer.ID,
targetCustomer.CountryID,
quantity,
).Row()
var (
id uint
base float64
excl float64
incl float64
tax float64
)
err := row.Scan(&id, &base, &excl, &incl, &tax)
if err != nil {
return err
}
product.PriceTaxExcl = excl
product.PriceTaxIncl = incl
return nil
}
func (s *ProductService) GetProductAttributes(
langID uint,
productID uint,
shopID uint,
customerID uint,
countryID uint,
quantity uint,
) ([]view.ProductAttribute, error) {
return s.productsRepo.GetProductVariants(langID, productID, shopID, customerID, countryID, quantity)
} }

13
app/view/product.go Normal file
View File

@@ -0,0 +1,13 @@
package view
import "encoding/json"
type ProductAttribute struct {
IDProductAttribute int64 `gorm:"column:id_product_attribute" json:"id_product_attribute"`
Reference string `gorm:"column:reference" json:"reference"`
BasePrice float64 `gorm:"column:base_price" json:"base_price"`
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"`
PriceTaxIncl float64 `gorm:"column:price_tax_incl" json:"price_tax_incl"`
Quantity int64 `gorm:"column:quantity" json:"quantity"`
Attributes json.RawMessage `gorm:"column:attributes" json:"attributes"`
}

View File

@@ -0,0 +1,22 @@
info:
name: Product Variants List
type: http
seq: 3
http:
method: GET
url: "{{bas_url}}/restricted/product/list-variants/{{product_id}}"
body:
type: json
data: ""
runtime:
variables:
- name: product_id
value: "2361"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View File

@@ -377,4 +377,537 @@ LIMIT
END // END //
DELIMITER ; DELIMITER ;
-----------------------------------
DELIMITER //
DROP PROCEDURE IF EXISTS get_product_attributes_with_price //
CREATE PROCEDURE get_product_attributes_with_price(
IN p_id_lang INT UNSIGNED,
IN p_id_product INT UNSIGNED,
IN p_id_shop INT UNSIGNED,
IN p_id_customer INT UNSIGNED,
IN p_id_country INT UNSIGNED,
IN p_quantity INT UNSIGNED
)
BEGIN
DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0;
DECLARE v_currency_rate DECIMAL(10,4) DEFAULT 1;
-- =========================================================
-- TAX
-- =========================================================
SELECT COALESCE(t.rate, 0)
INTO v_tax_rate
FROM ps_tax_rule tr
INNER JOIN ps_tax t ON t.id_tax = tr.id_tax
LEFT JOIN b2b_countries c ON c.id = p_id_country
WHERE tr.id_tax_rules_group = (
SELECT ps.id_tax_rules_group
FROM ps_product_shop ps
WHERE ps.id_product = p_id_product
AND ps.id_shop = p_id_shop
LIMIT 1
)
AND tr.id_country = c.ps_id_country
LIMIT 1;
-- =========================================================
-- CURRENCY
-- =========================================================
SELECT COALESCE(r.conversion_rate, 1)
INTO v_currency_rate
FROM b2b_countries c
LEFT JOIN b2b_currencies cur ON cur.id = c.b2b_id_currency
LEFT JOIN b2b_currency_rates r ON r.b2b_id_currency = cur.id
WHERE c.id = p_id_country
ORDER BY r.created_at DESC
LIMIT 1;
-- =========================================================
-- MAIN RESULT
-- =========================================================
SELECT
pa.id_product_attribute,
pa.reference,
-- =====================================================
-- BASE PRICE (product + attribute impact)
-- =====================================================
(
(COALESCE(ps.price, p.price) + COALESCE(pas.price, 0))
* v_currency_rate
) AS base_price,
-- =====================================================
-- FINAL PRICE EXCL (FULL RULE ENGINE)
-- =====================================================
COALESCE(sp.price_tax_excl,
(COALESCE(ps.price, p.price) + COALESCE(pas.price, 0)) * v_currency_rate
) AS price_tax_excl,
-- =====================================================
-- FINAL PRICE INCL
-- =====================================================
(
COALESCE(sp.price_tax_excl,
(COALESCE(ps.price, p.price) + COALESCE(pas.price, 0)) * v_currency_rate
)
) * (1 + v_tax_rate / 100) AS price_tax_incl,
-- =====================================================
-- STOCK
-- =====================================================
IFNULL(sa.quantity, 0) AS quantity,
-- =====================================================
-- ATTRIBUTES
-- =====================================================
(
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'group', agl.name,
'attribute', al.name
)
)
FROM ps_product_attribute_combination pac
JOIN ps_attribute a ON a.id_attribute = pac.id_attribute
JOIN ps_attribute_lang al
ON al.id_attribute = a.id_attribute AND al.id_lang = p_id_lang
JOIN ps_attribute_group_lang agl
ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = p_id_lang
WHERE pac.id_product_attribute = pa.id_product_attribute
) AS attributes
FROM ps_product_attribute pa
JOIN ps_product_attribute_shop pas
ON pas.id_product_attribute = pa.id_product_attribute
AND pas.id_shop = p_id_shop
JOIN ps_product p
ON p.id_product = pa.id_product
LEFT JOIN ps_product_shop ps
ON ps.id_product = p.id_product
AND ps.id_shop = p_id_shop
LEFT JOIN ps_stock_available sa
ON sa.id_product = pa.id_product
AND sa.id_product_attribute = pa.id_product_attribute
AND sa.id_shop = p_id_shop
-- =====================================================
-- FULL SPECIFIC PRICE ENGINE (IDENTICAL RULES TO MAIN)
-- =====================================================
LEFT JOIN (
SELECT *
FROM (
SELECT
pa.id_product_attribute,
-- FINAL PRICE
CASE
WHEN bsp.reduction_type = 'amount' THEN
CASE
WHEN bsp.b2b_id_currency IS NULL THEN bsp.price
ELSE bsp.price
END
WHEN bsp.reduction_type = 'percentage' THEN
(
(COALESCE(ps.price, p.price) + COALESCE(pas.price, 0))
* v_currency_rate
) * (1 - bsp.percentage_reduction / 100)
ELSE
(
(COALESCE(ps.price, p.price) + COALESCE(pas.price, 0))
* v_currency_rate
)
END AS price_tax_excl,
ROW_NUMBER() OVER (
PARTITION BY pa.id_product_attribute
ORDER BY
bsp.scope = 'product' DESC,
bsp.scope = 'category' DESC,
bsp.scope = 'shop' DESC,
bsp.from_quantity DESC,
bsp.id DESC
) AS rn
FROM ps_product_attribute pa
JOIN ps_product p ON p.id_product = pa.id_product
LEFT JOIN ps_product_shop ps
ON ps.id_product = p.id_product
AND ps.id_shop = p_id_shop
LEFT JOIN ps_product_attribute_shop pas
ON pas.id_product_attribute = pa.id_product_attribute
JOIN b2b_specific_price bsp ON bsp.is_active = TRUE
LEFT JOIN b2b_specific_price_product bsp_p
ON bsp_p.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_category bsp_c
ON bsp_c.b2b_specific_price_id = bsp.id
WHERE pa.id_product = p_id_product
-- =========================
-- SCOPE
-- =========================
AND (
(bsp.scope = 'product' AND bsp_p.id_product = p_id_product)
OR (
bsp.scope = 'category'
AND bsp_c.id_category IN (
SELECT id_category
FROM ps_category_product
WHERE id_product = p_id_product
)
)
OR (bsp.scope = 'shop')
)
-- =========================
-- CUSTOMER
-- =========================
AND (
NOT EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_customer = p_id_customer
)
)
-- =========================
-- COUNTRY
-- =========================
AND (
NOT EXISTS (
SELECT 1 FROM b2b_specific_price_country c
WHERE c.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_country c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_country = p_id_country
)
)
-- =========================
-- ATTRIBUTE
-- =========================
AND (
NOT EXISTS (
SELECT 1 FROM b2b_specific_price_product_attribute a
WHERE a.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1 FROM b2b_specific_price_product_attribute a
WHERE a.b2b_specific_price_id = bsp.id
AND a.id_product_attribute = pa.id_product_attribute
)
)
-- =========================
-- QUANTITY
-- =========================
AND bsp.from_quantity <= p_quantity
-- =========================
-- DATE
-- =========================
AND (
bsp.has_expiration_date = FALSE
OR (
(bsp.valid_from IS NULL OR bsp.valid_from <= NOW())
AND (bsp.valid_till IS NULL OR bsp.valid_till >= NOW())
)
)
) ranked
WHERE ranked.rn = 1
) sp ON sp.id_product_attribute = pa.id_product_attribute
WHERE pa.id_product = p_id_product;
END //
DELIMITER ;
--------------------------------
DELIMITER //
DROP PROCEDURE IF EXISTS get_product_price //
CREATE PROCEDURE get_product_price(
IN p_id_product INT UNSIGNED,
IN p_id_shop INT UNSIGNED,
IN p_id_customer INT UNSIGNED,
IN p_id_country INT UNSIGNED,
IN p_quantity INT UNSIGNED
)
BEGIN
DECLARE v_tax_rate DECIMAL(10,4) DEFAULT 0;
DECLARE v_currency_rate DECIMAL(10,4) DEFAULT 1;
DECLARE v_base_price DECIMAL(20,6) DEFAULT 0;
DECLARE v_final_excl DECIMAL(20,6) DEFAULT 0;
DECLARE v_final_incl DECIMAL(20,6) DEFAULT 0;
DECLARE v_has_specific INT DEFAULT 0;
DECLARE v_reduction_type VARCHAR(20);
DECLARE v_percentage DECIMAL(10,4);
DECLARE v_fixed_price DECIMAL(20,6);
DECLARE v_specific_currency_id INT;
-- =========================
-- 1. TAX RATE
-- =========================
SELECT COALESCE(t.rate, 0)
INTO v_tax_rate
FROM ps_tax_rule tr
INNER JOIN ps_tax t ON t.id_tax = tr.id_tax
LEFT JOIN b2b_countries c ON c.id = p_id_country
WHERE tr.id_tax_rules_group = (
SELECT ps.id_tax_rules_group
FROM ps_product_shop ps
WHERE ps.id_product = p_id_product
AND ps.id_shop = p_id_shop
LIMIT 1
)
AND tr.id_country = c.ps_id_country
ORDER BY tr.id_state DESC, tr.zipcode_from DESC, tr.id_tax_rule DESC
LIMIT 1;
-- =========================
-- 2. CURRENCY RATE
-- =========================
SELECT COALESCE(r.conversion_rate, 1)
INTO v_currency_rate
FROM b2b_countries c
LEFT JOIN b2b_currencies cur ON cur.id = c.b2b_id_currency
LEFT JOIN b2b_currency_rates r ON r.b2b_id_currency = cur.id
WHERE c.id = p_id_country
ORDER BY r.created_at DESC
LIMIT 1;
-- =========================
-- 3. BASE PRICE
-- =========================
SELECT COALESCE(ps.price, p.price) * v_currency_rate
INTO v_base_price
FROM ps_product p
LEFT JOIN ps_product_shop ps
ON ps.id_product = p.id_product
AND ps.id_shop = p_id_shop
WHERE p.id_product = p_id_product
LIMIT 1;
-- =========================
-- 4. SPECIFIC PRICE (correct wildcard-aware match)
-- =========================
SELECT
1,
bsp.reduction_type,
bsp.percentage_reduction,
bsp.price,
bsp.b2b_id_currency
INTO
v_has_specific,
v_reduction_type,
v_percentage,
v_fixed_price,
v_specific_currency_id
FROM b2b_specific_price bsp
LEFT JOIN b2b_specific_price_product bsp_p
ON bsp_p.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_category bsp_c
ON bsp_c.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_customer bsp_u
ON bsp_u.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_country bsp_ct
ON bsp_ct.b2b_specific_price_id = bsp.id
LEFT JOIN b2b_specific_price_product_attribute bsp_pa
ON bsp_pa.b2b_specific_price_id = bsp.id
WHERE bsp.is_active = TRUE
-- =========================
-- PRODUCT / CATEGORY / SHOP SCOPE
-- =========================
AND (
(bsp.scope = 'product' AND bsp_p.id_product = p_id_product)
OR (
bsp.scope = 'category'
AND bsp_c.id_category IN (
SELECT id_category
FROM ps_category_product
WHERE id_product = p_id_product
)
)
OR (bsp.scope = 'shop')
)
-- =========================
-- CUSTOMER RULE (wildcard-aware)
-- =========================
AND (
NOT EXISTS (
SELECT 1
FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1
FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_customer = p_id_customer
)
)
-- =========================
-- COUNTRY RULE (wildcard-aware)
-- =========================
AND (
NOT EXISTS (
SELECT 1
FROM b2b_specific_price_country c
WHERE c.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1
FROM b2b_specific_price_country c
WHERE c.b2b_specific_price_id = bsp.id
AND c.b2b_id_country = p_id_country
)
)
-- =========================
-- PRODUCT ATTRIBUTE RULE (wildcard-aware)
-- =========================
AND (
NOT EXISTS (
SELECT 1
FROM b2b_specific_price_product_attribute a
WHERE a.b2b_specific_price_id = bsp.id
)
OR EXISTS (
SELECT 1
FROM b2b_specific_price_product_attribute a
WHERE a.b2b_specific_price_id = bsp.id
AND a.id_product_attribute = 0
)
)
-- =========================
-- QUANTITY RULE
-- =========================
AND bsp.from_quantity <= p_quantity
-- =========================
-- DATE RULE (FIXED precedence bug)
-- =========================
AND (
bsp.has_expiration_date = FALSE
OR (
(bsp.valid_from IS NULL OR bsp.valid_from <= NOW())
AND (bsp.valid_till IS NULL OR bsp.valid_till >= NOW())
)
)
-- =========================
-- PRIORITY ORDERING (IMPROVED)
-- =========================
ORDER BY
-- strongest match wins
(EXISTS (
SELECT 1 FROM b2b_specific_price_customer c
WHERE c.b2b_specific_price_id = bsp.id
)) DESC,
(EXISTS (
SELECT 1 FROM b2b_specific_price_country c
WHERE c.b2b_specific_price_id = bsp.id
)) DESC,
bsp.scope = 'product' DESC,
bsp.scope = 'category' DESC,
bsp.scope = 'shop' DESC,
bsp.from_quantity DESC,
bsp.id DESC
LIMIT 1;
-- =========================
-- 5. APPLY SPECIFIC PRICE
-- =========================
SET v_final_excl = v_base_price;
IF v_has_specific = 1 THEN
IF v_reduction_type = 'amount' THEN
IF v_specific_currency_id IS NULL THEN
SET v_final_excl = v_fixed_price;
ELSE
SET v_final_excl = v_fixed_price; -- assume already converted or pre-handled
END IF;
ELSEIF v_reduction_type = 'percentage' THEN
SET v_final_excl = v_base_price * (1 - v_percentage / 100);
END IF;
END IF;
-- =========================
-- 6. TAX
-- =========================
SET v_final_incl = v_final_excl * (1 + v_tax_rate / 100);
-- =========================
-- 7. RETURN RESULT
-- =========================
SELECT
p_id_product AS id_product,
v_base_price AS price_base,
v_final_excl AS price_tax_excl,
v_final_incl AS price_tax_incl,
v_tax_rate AS tax_rate;
END //
DELIMITER ;
-- +goose Down -- +goose Down