diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index ddd8677..f251962 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -6,6 +6,7 @@ import ( "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/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/localeExtractor" "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("/list", handler.ListProducts) + r.Get("/list-variants/:product_id", handler.ListProductVariants) return r } @@ -84,13 +86,13 @@ func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - id_lang, ok := localeExtractor.GetLangID(c) - if !ok { + 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.Find(id_lang, paging, filters) + list, err := h.productService.Find(customer.LangID, paging, filters, customer, 1, constdata.SHOP_ID) if err != nil { return c.Status(responseErrors.GetErrorStatus(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", "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))) +} diff --git a/app/model/countries.go b/app/model/countries.go index 83600b5..43b4b42 100644 --- a/app/model/countries.go +++ b/app/model/countries.go @@ -1,15 +1,13 @@ package model -import "git.ma-al.com/goc_daniel/b2b/app/model/dbmodel" - // Represents a country together with its associated currency type Country struct { ID uint `gorm:"primaryKey;column:id" json:"id"` Name string `gorm:"column:name" json:"name"` Flag string `gorm:"size:16;not null;column:flag" json:"flag"` - PSCurrencyID uint `gorm:"column:currency_id" json:"currency_id"` - PSCurrency *dbmodel.PsCurrency `gorm:"foreignKey:PSCurrencyID;references:IDCurrency" json:"ps_currency"` + CurrencyID uint `gorm:"column:b2b_id_currency" json:"currency_id"` + Currency *Currency `gorm:"foreignKey:CurrencyID" json:"currency,omitempty"` } func (Country) TableName() string { diff --git a/app/model/customer.go b/app/model/customer.go index 77102ad..e05cc57 100644 --- a/app/model/customer.go +++ b/app/model/customer.go @@ -29,6 +29,7 @@ type Customer struct { LastLoginAt *time.Time `json:"last_login_at,omitempty"` LangID uint `gorm:"default:2" json:"lang_id"` // User's preferred language 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"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/app/model/product.go b/app/model/product.go index fa47790..5ead295 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -62,14 +62,16 @@ type Product struct { DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"` } type ProductInList struct { - ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"` - Name string `gorm:"column:name" json:"name" form:"name"` - LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"` - ImageLink string `gorm:"column:image_link" json:"image_link"` - CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"` - Reference string `gorm:"column:reference" json:"reference"` - VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` - Quantity int64 `gorm:"column:quantity" json:"quantity"` + ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"` + Name string `gorm:"column:name" json:"name" form:"name"` + LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"` + ImageLink string `gorm:"column:image_link" json:"image_link"` + CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"` + Reference string `gorm:"column:reference" json:"reference"` + VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` + 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 { diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 341b348..76b0aaf 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -10,13 +10,15 @@ import ( "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/find" + "git.ma-al.com/goc_daniel/b2b/app/view" "git.ma-al.com/goc_marek/gormcol" "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" ) 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) - 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{} @@ -46,10 +48,7 @@ func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_custo return &raw, nil } -func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (find.Found[model.ProductInList], error) { - var list []model.ProductInList - var total int64 - +func (repo *ProductsRepo) Find(langID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) { query := db.Get(). Table(gormcol.Field.Tab(dbmodel.PsProductShopCols.Active)+" AS ps"). Select(` @@ -63,9 +62,9 @@ func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.Filter sa.quantity AS quantity `, config.Get().Image.ImagePrefix). 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_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("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"). @@ -79,27 +78,62 @@ func (repo *ProductsRepo) Find(id_lang uint, p find.Paging, filt *filters.Filter }}). Order("ps.id_product DESC") - // Apply all filters - if filt != nil { - filt.ApplyAll(query) - } + query = query.Scopes(filt.All()...) - // run counter first as query is without limit and offset - err := query.Count(&total).Error + list, err := find.Paginate[model.ProductInList](langID, p, query) if err != nil { - return find.Found[model.ProductInList]{}, err + return nil, err } - - err = query. - Limit(p.Limit()). - Offset(p.Offset()). - Find(&list).Error - if err != nil { - return find.Found[model.ProductInList]{}, err - } - - return find.Found[model.ProductInList]{ - Items: list, - Count: uint(total), - }, nil + return &list, nil +} + +func (repo *ProductsRepo) GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error) { + var result []view.ProductAttribute + err := db.DB. + Raw(` + CALL get_product_attributes_with_price(?, ?, ?, ?, ?, ?) + `, + langID, + productID, + shopID, + customerID, + countryID, + quantity, + ). + Scan(&result).Error + + if err != nil { + return nil, err + } + + return result, 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 + } diff --git a/app/service/authService/auth.go b/app/service/authService/auth.go index ebc9e32..8916225 100644 --- a/app/service/authService/auth.go +++ b/app/service/authService/auth.go @@ -21,6 +21,7 @@ import ( "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // JWTClaims represents the JWT claims @@ -436,7 +437,7 @@ func (s *AuthService) RevokeAllRefreshTokens(userID uint) { // GetUserByID retrieves a user by ID func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) { 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) { return nil, responseErrors.ErrUserNotFound } diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 1a1620e..59d6c03 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -2,12 +2,15 @@ package productService import ( "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/repos/productsRepo" 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/find" + "git.ma-al.com/goc_daniel/b2b/app/view" ) 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 } -func (s *ProductService) Find(id_lang uint, p find.Paging, filters *filters.FiltersList) (find.Found[model.ProductInList], error) { - return s.productsRepo.Find(id_lang, p, filters) +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) { + 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) } diff --git a/app/view/product.go b/app/view/product.go new file mode 100644 index 0000000..505a380 --- /dev/null +++ b/app/view/product.go @@ -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"` +} diff --git a/bruno/api_v1/product/Product Variants List.yml b/bruno/api_v1/product/Product Variants List.yml new file mode 100644 index 0000000..e6edf3a --- /dev/null +++ b/bruno/api_v1/product/Product Variants List.yml @@ -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 diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index 8f7d5ab..eb8a6af 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -377,4 +377,537 @@ LIMIT END // 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