From 75af44b0dfdf2972239a96f60a46f1eefed1cd3d Mon Sep 17 00:00:00 2001 From: Wiktor Date: Thu, 9 Apr 2026 09:51:06 +0200 Subject: [PATCH 1/3] feat: product_attribute list with prices --- app/delivery/web/api/restricted/product.go | 32 +- app/model/countries.go | 6 +- app/model/customer.go | 1 + app/model/product.go | 18 +- app/repos/productsRepo/productsRepo.go | 88 ++- app/service/authService/auth.go | 3 +- app/service/productService/productService.go | 65 ++- app/view/product.go | 13 + .../api_v1/product/Product Variants List.yml | 22 + i18n/migrations/20260319163200_procedures.sql | 533 ++++++++++++++++++ 10 files changed, 736 insertions(+), 45 deletions(-) create mode 100644 app/view/product.go create mode 100644 bruno/api_v1/product/Product Variants List.yml 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 -- 2.49.1 From 54608410ea48f262e235510c8578356ed3c28669 Mon Sep 17 00:00:00 2001 From: Wiktor Date: Fri, 10 Apr 2026 14:49:05 +0200 Subject: [PATCH 2/3] feat: create specific price system and adapt product queries --- app/delivery/web/api/restricted/product.go | 2 +- .../web/api/restricted/specificPrice.go | 187 +++ app/delivery/web/init.go | 3 + app/model/specificPrice.go | 30 + app/repos/productsRepo/productsRepo.go | 80 +- .../specificPriceRepo/specificPriceRepo.go | 247 ++++ app/service/productService/productService.go | 126 +- .../specificPriceService.go | 136 ++ app/utils/responseErrors/responseErrors.go | 20 + app/view/product.go | 87 +- bruno/api_v1/Change Locales.yml | 2 +- bruno/api_v1/Delete Index - MeiliSearch.yml | 2 +- bruno/api_v1/Search Index Settings.yml | 2 +- bruno/api_v1/Search Items.yml | 2 +- bruno/api_v1/auth/folder.yml | 2 +- bruno/api_v1/currency/currency-rate.yml | 2 +- bruno/api_v1/currency/currency.yml | 2 +- bruno/api_v1/currency/folder.yml | 2 +- bruno/api_v1/customer/Customer (other).yml | 4 +- bruno/api_v1/customer/Customer list.yml | 4 +- bruno/api_v1/customer/folder.yml | 2 +- bruno/api_v1/product/Get Product.yml | 2 +- .../api_v1/product/Product Variants List.yml | 2 +- bruno/api_v1/product/Products List.yml | 5 +- bruno/api_v1/product/folder.yml | 2 +- bruno/api_v1/specific_price/Activate.yml | 20 + bruno/api_v1/specific_price/Create.yml | 27 + bruno/api_v1/specific_price/Deactivate.yml | 20 + bruno/api_v1/specific_price/Delete.yml | 20 + bruno/api_v1/specific_price/Get.yml | 20 + bruno/api_v1/specific_price/List.yml | 15 + bruno/api_v1/specific_price/Update.yml | 38 + bruno/api_v1/specific_price/folder.yml | 73 + i18n/migrations/20260319163200_procedures.sql | 1200 +++++------------ 34 files changed, 1449 insertions(+), 939 deletions(-) create mode 100644 app/delivery/web/api/restricted/specificPrice.go create mode 100644 app/model/specificPrice.go create mode 100644 app/repos/specificPriceRepo/specificPriceRepo.go create mode 100644 app/service/specificPriceService/specificPriceService.go create mode 100644 bruno/api_v1/specific_price/Activate.yml create mode 100644 bruno/api_v1/specific_price/Create.yml create mode 100644 bruno/api_v1/specific_price/Deactivate.yml create mode 100644 bruno/api_v1/specific_price/Delete.yml create mode 100644 bruno/api_v1/specific_price/Get.yml create mode 100644 bruno/api_v1/specific_price/List.yml create mode 100644 bruno/api_v1/specific_price/Update.yml create mode 100644 bruno/api_v1/specific_price/folder.yml diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index f251962..7eb11cf 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -70,7 +70,7 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - productJson, err := h.productService.GetJSON(p_id_product, int(customer.LangID), int(customer.ID), b2b_id_country, p_quantity) + productJson, err := h.productService.Get(uint(p_id_product), customer.LangID, customer.ID, uint(b2b_id_country), uint(p_quantity)) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/delivery/web/api/restricted/specificPrice.go b/app/delivery/web/api/restricted/specificPrice.go new file mode 100644 index 0000000..5658b99 --- /dev/null +++ b/app/delivery/web/api/restricted/specificPrice.go @@ -0,0 +1,187 @@ +package restricted + +import ( + "strconv" + "time" + + "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/specificPriceService" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" + "github.com/gofiber/fiber/v3" +) + +type SpecificPriceHandler struct { + SpecificPriceService *specificPriceService.SpecificPriceService + config *config.Config +} + +func NewSpecificPriceHandler() *SpecificPriceHandler { + SpecificPriceService := specificPriceService.New() + return &SpecificPriceHandler{ + SpecificPriceService: SpecificPriceService, + config: config.Get(), + } +} + +func SpecificPriceHandlerRoutes(r fiber.Router) fiber.Router { + handler := NewSpecificPriceHandler() + + r.Post("/", handler.Create) + r.Put("/:id", handler.Update) + r.Delete("/:id", handler.Delete) + r.Get("/", handler.List) + r.Get("/:id", handler.GetByID) + r.Patch("/:id/activate", handler.Activate) + r.Patch("/:id/deactivate", handler.Deactivate) + + return r +} + +func (h *SpecificPriceHandler) Create(c fiber.Ctx) error { + var pr model.SpecificPrice + if err := c.Bind().Body(&pr); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + result, err := h.SpecificPriceService.Create(c.Context(), &pr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.Status(fiber.StatusCreated).JSON(result) +} + +func (h *SpecificPriceHandler) Update(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + var pr model.SpecificPrice + if err := c.Bind().Body(&pr); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + result, err := h.SpecificPriceService.Update(c.Context(), id, &pr) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(result) +} + +func (h *SpecificPriceHandler) List(c fiber.Ctx) error { + result, err := h.SpecificPriceService.List(c.Context()) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(result) +} + +func (h *SpecificPriceHandler) GetByID(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + result, err := h.SpecificPriceService.GetByID(c.Context(), id) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(result) +} + +func (h *SpecificPriceHandler) Activate(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + err = h.SpecificPriceService.SetActive(c.Context(), id, true) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(fiber.Map{ + "message": "price reduction activated", + }) +} + +func (h *SpecificPriceHandler) Deactivate(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + err = h.SpecificPriceService.SetActive(c.Context(), id, false) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(fiber.Map{ + "message": "price reduction deactivated", + }) +} + +func (h *SpecificPriceHandler) Delete(c fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), + }) + } + + err = h.SpecificPriceService.Delete(c.Context(), id) + if err != nil { + return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ + "error": responseErrors.GetErrorCode(c, err), + }) + } + + return c.JSON(fiber.Map{ + "message": "specific price deleted", + }) +} + +func parseTime(s *string) *time.Time { + if s == nil { + return nil + } + t, err := time.Parse(time.RFC3339, *s) + if err != nil { + return nil + } + return &t +} diff --git a/app/delivery/web/init.go b/app/delivery/web/init.go index 9d673f5..d3a0cc3 100644 --- a/app/delivery/web/init.go +++ b/app/delivery/web/init.go @@ -117,6 +117,9 @@ func (s *Server) Setup() error { carts := s.restricted.Group("/carts") restricted.CartsHandlerRoutes(carts) + specificPrice := s.restricted.Group("/specific-price") + restricted.SpecificPriceHandlerRoutes(specificPrice) + restricted.CurrencyHandlerRoutes(s.restricted) s.api.All("*", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNotFound) diff --git a/app/model/specificPrice.go b/app/model/specificPrice.go new file mode 100644 index 0000000..e602bd4 --- /dev/null +++ b/app/model/specificPrice.go @@ -0,0 +1,30 @@ +package model + +import "time" + +type SpecificPrice struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Scope string `gorm:"type:varchar(20);not null" json:"scope"` + ValidFrom *time.Time `gorm:"null" json:"valid_from"` + ValidTill *time.Time `gorm:"null" json:"valid_till"` + HasExpirationDate bool `gorm:"default:false" json:"has_expiration_date"` + ReductionType string `gorm:"type:enum('amount','percentage');not null" json:"reduction_type"` + Price *float64 `gorm:"type:decimal(10,2);null" json:"price"` + CurrencyID *uint64 `gorm:"column:b2b_id_currency;null" json:"currency_id"` + PercentageReduction *float64 `gorm:"type:decimal(5,2);null" json:"percentage_reduction"` + FromQuantity uint32 `gorm:"default:1" json:"from_quantity"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt *time.Time `gorm:"null" json:"created_at"` + UpdatedAt *time.Time `gorm:"null" json:"updated_at"` + + ProductIDs []uint64 `gorm:"-" json:"product_ids"` + CategoryIDs []uint64 `gorm:"-" json:"category_ids"` + ProductAttributeIDs []uint64 `gorm:"-" json:"product_attribute_ids"` + CountryIDs []uint64 `gorm:"-" json:"country_ids"` + CustomerIDs []uint64 `gorm:"-" json:"customer_ids"` +} + +func (SpecificPrice) TableName() string { + return "b2b_specific_price" +} diff --git a/app/repos/productsRepo/productsRepo.go b/app/repos/productsRepo/productsRepo.go index 76b0aaf..2255832 100644 --- a/app/repos/productsRepo/productsRepo.go +++ b/app/repos/productsRepo/productsRepo.go @@ -2,7 +2,6 @@ package productsRepo import ( "encoding/json" - "fmt" "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" @@ -16,9 +15,12 @@ import ( ) 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) GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error) + GetBase(p_id_product, p_id_shop, p_id_lang uint) (view.Product, error) + GetPrice(p_id_product uint, productAttributeID *uint, p_id_shop uint, p_id_customer uint, p_id_country uint, p_quantity uint) (view.Price, error) + GetVariants(p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity uint) ([]view.ProductAttribute, error) } type ProductsRepo struct{} @@ -27,25 +29,75 @@ func New() UIProductsRepo { return &ProductsRepo{} } -func (repo *ProductsRepo) GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) { - var productStr string // ← Scan as string first +func (repo *ProductsRepo) GetBase(p_id_product, p_id_shop, p_id_lang uint) (view.Product, error) { + var result view.Product - err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`, - p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity). - Scan(&productStr). - Error + err := db.DB.Raw(`CALL get_product_base(?,?,?)`, + p_id_product, p_id_shop, p_id_lang). + Scan(&result).Error + + return result, err +} + +func (repo *ProductsRepo) GetPrice( + p_id_product uint, productAttributeID *uint, p_id_shop uint, p_id_customer uint, p_id_country uint, p_quantity uint, +) (view.Price, error) { + + type row struct { + Price json.RawMessage `gorm:"column:price"` + } + + var r row + err := db.DB.Raw(` + SELECT fn_product_price(?,?,?,?,?,?) AS price`, + p_id_product, p_id_shop, p_id_customer, p_id_country, p_quantity, productAttributeID). + Scan(&r).Error if err != nil { - return nil, err + return view.Price{}, err } - // Optional: validate it's valid JSON - if !json.Valid([]byte(productStr)) { - return nil, fmt.Errorf("invalid json returned from stored procedure") + var temp struct { + Base json.Number `json:"base"` + FinalTaxExcl json.Number `json:"final_tax_excl"` + FinalTaxIncl json.Number `json:"final_tax_incl"` + TaxRate json.Number `json:"tax_rate"` + Priority json.Number `json:"priority"` } - raw := json.RawMessage(productStr) - return &raw, nil + if err := json.Unmarshal(r.Price, &temp); err != nil { + return view.Price{}, err + } + + price := view.Price{ + Base: mustParseFloat(temp.Base), + FinalTaxExcl: mustParseFloat(temp.FinalTaxExcl), + FinalTaxIncl: mustParseFloat(temp.FinalTaxIncl), + TaxRate: mustParseFloat(temp.TaxRate), + Priority: mustParseInt(temp.Priority), + } + + return price, nil +} +func mustParseFloat(n json.Number) float64 { + f, _ := n.Float64() + return f +} + +func mustParseInt(n json.Number) int { + i, _ := n.Int64() + return int(i) +} +func (repo *ProductsRepo) GetVariants(p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity uint) ([]view.ProductAttribute, error) { + + var results []view.ProductAttribute + + err := db.DB.Raw(` + CALL get_product_variants(?,?,?,?,?,?)`, + p_id_product, p_id_shop, p_id_lang, p_id_customer, p_id_country, p_quantity). + Scan(&results).Error + + return results, err } func (repo *ProductsRepo) Find(langID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) { diff --git a/app/repos/specificPriceRepo/specificPriceRepo.go b/app/repos/specificPriceRepo/specificPriceRepo.go new file mode 100644 index 0000000..e67749b --- /dev/null +++ b/app/repos/specificPriceRepo/specificPriceRepo.go @@ -0,0 +1,247 @@ +package specificPriceRepo + +import ( + "context" + "errors" + "time" + + "git.ma-al.com/goc_daniel/b2b/app/db" + "git.ma-al.com/goc_daniel/b2b/app/model" + "gorm.io/gorm" +) + +type UISpecificPriceRepo interface { + Create(ctx context.Context, pr *model.SpecificPrice) error + Update(ctx context.Context, pr *model.SpecificPrice) error + Delete(ctx context.Context, id uint64) error + GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error) + List(ctx context.Context) ([]*model.SpecificPrice, error) + SetActive(ctx context.Context, id uint64, active bool) error +} + +type SpecificPriceRepo struct{} + +func New() UISpecificPriceRepo { + return &SpecificPriceRepo{} +} + +func (repo *SpecificPriceRepo) Create(ctx context.Context, pr *model.SpecificPrice) error { + return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + pr.CreatedAt = &now + + if err := tx.Create(pr).Error; err != nil { + return err + } + + return repo.insertRelations(tx, pr) + }) +} + +func (repo *SpecificPriceRepo) Update(ctx context.Context, pr *model.SpecificPrice) error { + return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + pr.UpdatedAt = &now + + if err := tx.Save(pr).Error; err != nil { + return err + } + + if err := repo.clearRelations(tx, pr.ID); err != nil { + return err + } + + return repo.insertRelations(tx, pr) + }) +} + +func (repo *SpecificPriceRepo) GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error) { + var pr model.SpecificPrice + err := db.DB.WithContext(ctx).Where("id = ?", id).First(&pr).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + if err := repo.loadRelations(ctx, &pr); err != nil { + return nil, err + } + + return &pr, nil +} + +func (repo *SpecificPriceRepo) List(ctx context.Context) ([]*model.SpecificPrice, error) { + var specificPrices []*model.SpecificPrice + err := db.DB.WithContext(ctx).Find(&specificPrices).Error + if err != nil { + return nil, err + } + + for i := range specificPrices { + if err := repo.loadRelations(ctx, specificPrices[i]); err != nil { + return nil, err + } + } + + return specificPrices, nil +} + +func (repo *SpecificPriceRepo) SetActive(ctx context.Context, id uint64, active bool) error { + return db.DB.WithContext(ctx).Model(&model.SpecificPrice{}).Where("id = ?", id).Update("is_active", active).Error +} + +func (repo *SpecificPriceRepo) Delete(ctx context.Context, id uint64) error { + return db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("DELETE FROM b2b_specific_price_product WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_category WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_product_attribute WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_country WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_customer WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Delete(&model.SpecificPrice{}, "id = ?", id).Error; err != nil { + return err + } + return nil + }) +} + +func (repo *SpecificPriceRepo) insertRelations(tx *gorm.DB, pr *model.SpecificPrice) error { + if len(pr.ProductIDs) > 0 { + for _, productID := range pr.ProductIDs { + if err := tx.Exec(` + INSERT INTO b2b_specific_price_product (b2b_specific_price_id, id_product) VALUES (?, ?) + `, pr.ID, productID).Error; err != nil { + return err + } + } + } + + if len(pr.CategoryIDs) > 0 { + for _, categoryID := range pr.CategoryIDs { + if err := tx.Exec(` + INSERT INTO b2b_specific_price_category (b2b_specific_price_id, id_category) VALUES (?, ?) + `, pr.ID, categoryID).Error; err != nil { + return err + } + } + } + + if len(pr.ProductAttributeIDs) > 0 { + for _, attrID := range pr.ProductAttributeIDs { + if err := tx.Exec(` + INSERT INTO b2b_specific_price_product_attribute (b2b_specific_price_id, id_product_attribute) VALUES (?, ?) + `, pr.ID, attrID).Error; err != nil { + return err + } + } + } + + if len(pr.CountryIDs) > 0 { + for _, countryID := range pr.CountryIDs { + if err := tx.Exec(` + INSERT INTO b2b_specific_price_country (b2b_specific_price_id, b2b_id_country) VALUES (?, ?) + `, pr.ID, countryID).Error; err != nil { + return err + } + } + } + + if len(pr.CustomerIDs) > 0 { + for _, customerID := range pr.CustomerIDs { + if err := tx.Exec(` + INSERT INTO b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer) VALUES (?, ?) + `, pr.ID, customerID).Error; err != nil { + return err + } + } + } + + return nil +} + +func (repo *SpecificPriceRepo) clearRelations(tx *gorm.DB, id uint64) error { + if err := tx.Exec("DELETE FROM b2b_specific_price_product WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_category WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_product_attribute WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_country WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + if err := tx.Exec("DELETE FROM b2b_specific_price_customer WHERE b2b_specific_price_id = ?", id).Error; err != nil { + return err + } + + return nil +} + +func (repo *SpecificPriceRepo) loadRelations(ctx context.Context, pr *model.SpecificPrice) error { + var err error + + var productIDs []struct { + IDProduct uint64 `gorm:"column:id_product"` + } + if err = db.DB.WithContext(ctx).Table("b2b_specific_price_product").Where("b2b_specific_price_id = ?", pr.ID).Select("id_product").Scan(&productIDs).Error; err != nil { + return err + } + for _, p := range productIDs { + pr.ProductIDs = append(pr.ProductIDs, p.IDProduct) + } + + var categoryIDs []struct { + IDCategory uint64 `gorm:"column:id_category"` + } + if err = db.DB.WithContext(ctx).Table("b2b_specific_price_category").Where("b2b_specific_price_id = ?", pr.ID).Select("id_category").Scan(&categoryIDs).Error; err != nil { + return err + } + for _, c := range categoryIDs { + pr.CategoryIDs = append(pr.CategoryIDs, c.IDCategory) + } + + var attrIDs []struct { + IDAttr uint64 `gorm:"column:id_product_attribute"` + } + if err = db.DB.WithContext(ctx).Table("b2b_specific_price_product_attribute").Where("b2b_specific_price_id = ?", pr.ID).Select("id_product_attribute").Scan(&attrIDs).Error; err != nil { + return err + } + for _, a := range attrIDs { + pr.ProductAttributeIDs = append(pr.ProductAttributeIDs, a.IDAttr) + } + + var countryIDs []struct { + IDCountry uint64 `gorm:"column:b2b_id_country"` + } + if err = db.DB.WithContext(ctx).Table("b2b_specific_price_country").Where("b2b_specific_price_id = ?", pr.ID).Select("b2b_id_country").Scan(&countryIDs).Error; err != nil { + return err + } + for _, c := range countryIDs { + pr.CountryIDs = append(pr.CountryIDs, c.IDCountry) + } + + var customerIDs []struct { + IDCustomer uint64 `gorm:"column:b2b_id_customer"` + } + if err = db.DB.WithContext(ctx).Table("b2b_specific_price_customer").Where("b2b_specific_price_id = ?", pr.ID).Select("b2b_id_customer").Scan(&customerIDs).Error; err != nil { + return err + } + for _, c := range customerIDs { + pr.CustomerIDs = append(pr.CustomerIDs, c.IDCustomer) + } + + return nil +} diff --git a/app/service/productService/productService.go b/app/service/productService/productService.go index 59d6c03..d16c075 100644 --- a/app/service/productService/productService.go +++ b/app/service/productService/productService.go @@ -4,7 +4,6 @@ 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" @@ -23,64 +22,90 @@ func New() *ProductService { } } -func (s *ProductService) GetJSON(p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) { - products, err := s.productsRepo.GetJSON(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity) - if err != nil { - return products, err - } +func (s *ProductService) Get( + p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity uint, +) (*json.RawMessage, error) { - return products, nil -} - -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) + product, err := s.productsRepo.GetBase(p_id_product, constdata.SHOP_ID, p_id_lang) if err != nil { return nil, err } + + price, err := s.productsRepo.GetPrice(p_id_product, nil, constdata.SHOP_ID, p_id_customer, b2b_id_country, p_quantity) + if err != nil { + return nil, err + } + + variants, err := s.productsRepo.GetVariants(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity) + if err != nil { + return nil, err + } + + result := view.ProductFull{ + Product: product, + Price: price, + Variants: variants, + } + + if len(variants) > 0 { + result.Variants = variants + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return nil, err + } + + raw := json.RawMessage(jsonBytes) + return &raw, nil +} + +func (s *ProductService) Find( + idLang uint, + p find.Paging, + filters *filters.FiltersList, + customer *model.Customer, + quantity uint, + shopID uint, +) (*find.Found[model.ProductInList], error) { + + if customer == nil || customer.Country == nil { + return nil, errors.New("customer is nil or missing fields") + } + + found, err := s.productsRepo.Find(idLang, p, filters) + if err != nil { + return nil, err + } + + // 1. collect simple products (no variants) + simpleProductIndexes := make([]int, 0, len(found.Items)) + 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 - } + simpleProductIndexes = append(simpleProductIndexes, i) } } - return found, err -} + // 2. resolve prices ONLY for simple products + for _, i := range simpleProductIndexes { + price, err := s.productsRepo.GetPrice( + found.Items[i].ProductID, + nil, + shopID, + customer.ID, + customer.CountryID, + quantity, + ) + if err != nil { + return nil, 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 + found.Items[i].PriceTaxExcl = price.FinalTaxExcl + found.Items[i].PriceTaxIncl = price.FinalTaxIncl } - product.PriceTaxExcl = excl - product.PriceTaxIncl = incl - return nil - + return found, nil } func (s *ProductService) GetProductAttributes( @@ -91,5 +116,10 @@ func (s *ProductService) GetProductAttributes( countryID uint, quantity uint, ) ([]view.ProductAttribute, error) { - return s.productsRepo.GetProductVariants(langID, productID, shopID, customerID, countryID, quantity) + variants, err := s.productsRepo.GetVariants(productID, constdata.SHOP_ID, langID, customerID, countryID, quantity) + if err != nil { + return nil, err + } + + return variants, nil } diff --git a/app/service/specificPriceService/specificPriceService.go b/app/service/specificPriceService/specificPriceService.go new file mode 100644 index 0000000..7044284 --- /dev/null +++ b/app/service/specificPriceService/specificPriceService.go @@ -0,0 +1,136 @@ +package specificPriceService + +import ( + "context" + + "git.ma-al.com/goc_daniel/b2b/app/model" + "git.ma-al.com/goc_daniel/b2b/app/repos/specificPriceRepo" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" +) + +type SpecificPriceService struct { + specificPriceRepo specificPriceRepo.UISpecificPriceRepo +} + +func New() *SpecificPriceService { + return &SpecificPriceService{ + specificPriceRepo: specificPriceRepo.New(), + } +} + +func (s *SpecificPriceService) Create(ctx context.Context, pr *model.SpecificPrice) (*model.SpecificPrice, error) { + if err := s.validateRequest(pr); err != nil { + return nil, err + } + + if pr.Scope == "shop" && len(pr.ProductIDs) == 0 && len(pr.CategoryIDs) == 0 && len(pr.ProductAttributeIDs) == 0 && len(pr.CountryIDs) == 0 && len(pr.CustomerIDs) == 0 { + // pr.Scope = "global" + } else { + // pr.Scope = "scoped" + } + + if err := s.specificPriceRepo.Create(ctx, pr); err != nil { + return nil, err + } + + return pr, nil +} + +func (s *SpecificPriceService) Update(ctx context.Context, id uint64, pr *model.SpecificPrice) (*model.SpecificPrice, error) { + existing, err := s.specificPriceRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, responseErrors.ErrSpecificPriceNotFound + } + + if err := s.validateUpdateRequest(pr); err != nil { + return nil, err + } + + pr.ID = id + + if pr.Scope == "shop" && len(pr.ProductIDs) == 0 && len(pr.CategoryIDs) == 0 && len(pr.ProductAttributeIDs) == 0 && len(pr.CountryIDs) == 0 && len(pr.CustomerIDs) == 0 { + // pr.Scope = "global" + } else { + // pr.Scope = "scoped" + } + + if err := s.specificPriceRepo.Update(ctx, pr); err != nil { + return nil, err + } + + return pr, nil +} + +func (s *SpecificPriceService) GetByID(ctx context.Context, id uint64) (*model.SpecificPrice, error) { + pr, err := s.specificPriceRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if pr == nil { + return nil, responseErrors.ErrSpecificPriceNotFound + } + return pr, nil +} + +func (s *SpecificPriceService) List(ctx context.Context) ([]*model.SpecificPrice, error) { + return s.specificPriceRepo.List(ctx) +} + +func (s *SpecificPriceService) SetActive(ctx context.Context, id uint64, active bool) error { + pr, err := s.specificPriceRepo.GetByID(ctx, id) + if err != nil { + return err + } + if pr == nil { + return responseErrors.ErrSpecificPriceNotFound + } + + return s.specificPriceRepo.SetActive(ctx, id, active) +} + +func (s *SpecificPriceService) Delete(ctx context.Context, id uint64) error { + pr, err := s.specificPriceRepo.GetByID(ctx, id) + if err != nil { + return err + } + if pr == nil { + return responseErrors.ErrSpecificPriceNotFound + } + + return s.specificPriceRepo.Delete(ctx, id) +} + +func (s *SpecificPriceService) validateRequest(pr *model.SpecificPrice) error { + if pr.ReductionType != "amount" && pr.ReductionType != "percentage" { + return responseErrors.ErrInvalidReductionType + } + + if pr.ReductionType == "percentage" && pr.PercentageReduction == nil { + return responseErrors.ErrPercentageRequired + } + + if pr.ReductionType == "amount" && pr.Price == nil { + return responseErrors.ErrPriceRequired + } + + return nil +} + +func (s *SpecificPriceService) validateUpdateRequest(pr *model.SpecificPrice) error { + if pr.ReductionType != "" && pr.ReductionType != "amount" && pr.ReductionType != "percentage" { + return responseErrors.ErrInvalidReductionType + } + + if pr.ReductionType == "percentage" && pr.PercentageReduction == nil { + return responseErrors.ErrPercentageRequired + } + + if pr.ReductionType == "amount" && pr.Price == nil { + return responseErrors.ErrPriceRequired + } + + return nil +} diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index dc54a4f..a703d62 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -62,6 +62,12 @@ var ( ErrUserHasNoSuchCart = errors.New("user does not have cart with given id") ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist") + // Typed errors for price reduction handler + ErrInvalidReductionType = errors.New("invalid reduction type: must be 'amount' or 'percentage'") + ErrPercentageRequired = errors.New("percentage_reduction required when reduction_type is percentage") + ErrPriceRequired = errors.New("price required when reduction_type is amount") + ErrSpecificPriceNotFound = errors.New("price reduction not found") + // Typed errors for data parsing ErrJSONBody = errors.New("invalid JSON body") ) @@ -171,6 +177,15 @@ func GetErrorCode(c fiber.Ctx, err error) string { case errors.Is(err, ErrProductOrItsVariationDoesNotExist): return i18n.T_(c, "error.product_or_its_variation_does_not_exist") + case errors.Is(err, ErrInvalidReductionType): + return i18n.T_(c, "error.invalid_reduction_type") + case errors.Is(err, ErrPercentageRequired): + return i18n.T_(c, "error.percentage_required") + case errors.Is(err, ErrPriceRequired): + return i18n.T_(c, "error.price_required") + case errors.Is(err, ErrSpecificPriceNotFound): + return i18n.T_(c, "error.price_reduction_not_found") + case errors.Is(err, ErrJSONBody): return i18n.T_(c, "error.err_json_body") @@ -219,8 +234,13 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrUserHasNoSuchCart), errors.Is(err, ErrProductOrItsVariationDoesNotExist), + errors.Is(err, ErrInvalidReductionType), + errors.Is(err, ErrPercentageRequired), + errors.Is(err, ErrPriceRequired), errors.Is(err, ErrJSONBody): return fiber.StatusBadRequest + case errors.Is(err, ErrSpecificPriceNotFound): + return fiber.StatusNotFound case errors.Is(err, ErrEmailExists): return fiber.StatusConflict case errors.Is(err, ErrAIResponseFail), diff --git a/app/view/product.go b/app/view/product.go index 505a380..9078a13 100644 --- a/app/view/product.go +++ b/app/view/product.go @@ -1,9 +1,12 @@ package view -import "encoding/json" +import ( + "encoding/json" + "time" +) type ProductAttribute struct { - IDProductAttribute int64 `gorm:"column:id_product_attribute" json:"id_product_attribute"` + IDProductAttribute uint `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"` @@ -11,3 +14,83 @@ type ProductAttribute struct { Quantity int64 `gorm:"column:quantity" json:"quantity"` Attributes json.RawMessage `gorm:"column:attributes" json:"attributes"` } + +type Price struct { + Base float64 `json:"base"` + FinalTaxExcl float64 `json:"final_tax_excl"` + FinalTaxIncl float64 `json:"final_tax_incl"` + TaxRate float64 `json:"tax_rate"` + Priority int `json:"priority"` // or string +} + +type Variant struct { + ID uint `json:"id_product_attribute"` + Reference string `json:"reference"` + + BasePrice float64 `json:"base_price"` + FinalExcl float64 `json:"final_tax_excl"` + FinalIncl float64 `json:"final_tax_incl"` + + Stock int `json:"stock"` +} +type ProductFull struct { + Product Product `json:"product"` + Price Price `json:"price"` + Variants []ProductAttribute `json:"variants,omitempty"` +} + +type Product struct { + ID uint `gorm:"column:id" json:"id"` + Reference string `gorm:"column:reference" json:"reference"` + SupplierReference string `gorm:"column:supplier_reference" json:"supplier_reference,omitempty"` + EAN13 string `gorm:"column:ean13" json:"ean13,omitempty"` + UPC string `gorm:"column:upc" json:"upc,omitempty"` + ISBN string `gorm:"column:isbn" json:"isbn,omitempty"` + + // Basic Price (from product table) + BasePrice float64 `gorm:"column:base_price" json:"base_price"` + WholesalePrice float64 `gorm:"column:wholesale_price" json:"wholesale_price,omitempty"` + Unity string `gorm:"column:unity" json:"unity,omitempty"` + UnitPriceRatio float64 `gorm:"column:unit_price_ratio" json:"unit_price_ratio,omitempty"` + + // Stock & Availability + Quantity int `gorm:"column:quantity" json:"quantity"` + MinimalQuantity int `gorm:"column:minimal_quantity" json:"minimal_quantity"` + AvailableForOrder bool `gorm:"column:available_for_order" json:"available_for_order"` + AvailableDate string `gorm:"column:available_date" json:"available_date,omitempty"` + OutOfStockBehavior int `gorm:"column:out_of_stock_behavior" json:"out_of_stock_behavior"` + + // Flags + OnSale bool `gorm:"column:on_sale" json:"on_sale"` + ShowPrice bool `gorm:"column:show_price" json:"show_price"` + Condition string `gorm:"column:condition" json:"condition"` + IsVirtual bool `gorm:"column:is_virtual" json:"is_virtual"` + + // Physical + Weight float64 `gorm:"column:weight" json:"weight"` + Width float64 `gorm:"column:width" json:"width"` + Height float64 `gorm:"column:height" json:"height"` + Depth float64 `gorm:"column:depth" json:"depth"` + AdditionalShippingCost float64 `gorm:"column:additional_shipping_cost" json:"additional_shipping_cost,omitempty"` + + // Delivery + DeliveryDays int `gorm:"column:delivery_days" json:"delivery_days,omitempty"` + + // Status + Active bool `gorm:"column:active" json:"active"` + Visibility string `gorm:"column:visibility" json:"visibility"` + Indexed bool `gorm:"column:indexed" json:"indexed"` + + // Timestamps + DateAdd time.Time `gorm:"column:date_add" json:"date_add"` + DateUpd time.Time `gorm:"column:date_upd" json:"date_upd"` + + // Language fields + Name string `gorm:"column:name" json:"name"` + Description string `gorm:"column:description" json:"description"` + DescriptionShort string `gorm:"column:description_short" json:"description_short"` + + // Relations + Manufacturer string `gorm:"column:manufacturer" json:"manufacturer"` + Category string `gorm:"column:category" json:"category"` +} diff --git a/bruno/api_v1/Change Locales.yml b/bruno/api_v1/Change Locales.yml index 4e2388e..95af642 100644 --- a/bruno/api_v1/Change Locales.yml +++ b/bruno/api_v1/Change Locales.yml @@ -1,7 +1,7 @@ info: name: Change Locales type: http - seq: 3 + seq: 5 http: method: POST diff --git a/bruno/api_v1/Delete Index - MeiliSearch.yml b/bruno/api_v1/Delete Index - MeiliSearch.yml index b18e531..e5e011e 100644 --- a/bruno/api_v1/Delete Index - MeiliSearch.yml +++ b/bruno/api_v1/Delete Index - MeiliSearch.yml @@ -1,7 +1,7 @@ info: name: Delete Index - MeiliSearch type: http - seq: 5 + seq: 7 http: method: DELETE diff --git a/bruno/api_v1/Search Index Settings.yml b/bruno/api_v1/Search Index Settings.yml index b11cd07..4e2c4bc 100644 --- a/bruno/api_v1/Search Index Settings.yml +++ b/bruno/api_v1/Search Index Settings.yml @@ -1,7 +1,7 @@ info: name: Search Index Settings type: http - seq: 4 + seq: 6 http: method: POST diff --git a/bruno/api_v1/Search Items.yml b/bruno/api_v1/Search Items.yml index 135daab..a092062 100644 --- a/bruno/api_v1/Search Items.yml +++ b/bruno/api_v1/Search Items.yml @@ -1,7 +1,7 @@ info: name: Search Items type: http - seq: 2 + seq: 4 http: method: POST diff --git a/bruno/api_v1/auth/folder.yml b/bruno/api_v1/auth/folder.yml index 4d04d32..e8485e1 100644 --- a/bruno/api_v1/auth/folder.yml +++ b/bruno/api_v1/auth/folder.yml @@ -1,7 +1,7 @@ info: name: auth type: folder - seq: 6 + seq: 2 request: auth: inherit diff --git a/bruno/api_v1/currency/currency-rate.yml b/bruno/api_v1/currency/currency-rate.yml index b741b82..269dea4 100644 --- a/bruno/api_v1/currency/currency-rate.yml +++ b/bruno/api_v1/currency/currency-rate.yml @@ -11,7 +11,7 @@ http: data: |- { "b2b_id_currency" : 1, - "conversion_rate": 4.2 + "conversion_rate": 3 } auth: inherit diff --git a/bruno/api_v1/currency/currency.yml b/bruno/api_v1/currency/currency.yml index b3de3e9..4c444d8 100644 --- a/bruno/api_v1/currency/currency.yml +++ b/bruno/api_v1/currency/currency.yml @@ -11,7 +11,7 @@ http: runtime: variables: - name: id - value: "1" + value: "2" settings: encodeUrl: true diff --git a/bruno/api_v1/currency/folder.yml b/bruno/api_v1/currency/folder.yml index e409d83..7f299b6 100644 --- a/bruno/api_v1/currency/folder.yml +++ b/bruno/api_v1/currency/folder.yml @@ -1,7 +1,7 @@ info: name: currency type: folder - seq: 8 + seq: 9 request: auth: inherit diff --git a/bruno/api_v1/customer/Customer (other).yml b/bruno/api_v1/customer/Customer (other).yml index 161094d..803b6a1 100644 --- a/bruno/api_v1/customer/Customer (other).yml +++ b/bruno/api_v1/customer/Customer (other).yml @@ -5,10 +5,10 @@ info: http: method: GET - url: "{{bas_url}}/restricted/customer?id=1" + url: "{{bas_url}}/restricted/customer?id=2" params: - name: id - value: "1" + value: "2" type: query auth: inherit diff --git a/bruno/api_v1/customer/Customer list.yml b/bruno/api_v1/customer/Customer list.yml index 11c286b..b80318d 100644 --- a/bruno/api_v1/customer/Customer list.yml +++ b/bruno/api_v1/customer/Customer list.yml @@ -5,10 +5,10 @@ info: http: method: GET - url: "{{bas_url}}/restricted/customer/list?search=" + url: "{{bas_url}}/restricted/customer/list?search=marek" params: - name: search - value: "" + value: marek type: query auth: inherit diff --git a/bruno/api_v1/customer/folder.yml b/bruno/api_v1/customer/folder.yml index cdd2d6f..50228ad 100644 --- a/bruno/api_v1/customer/folder.yml +++ b/bruno/api_v1/customer/folder.yml @@ -1,7 +1,7 @@ info: name: customer type: folder - seq: 9 + seq: 10 request: auth: inherit diff --git a/bruno/api_v1/product/Get Product.yml b/bruno/api_v1/product/Get Product.yml index b9b182e..83f9525 100644 --- a/bruno/api_v1/product/Get Product.yml +++ b/bruno/api_v1/product/Get Product.yml @@ -5,7 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/product/200/1/5" + url: "{{bas_url}}/restricted/product/51/1/7" auth: inherit settings: diff --git a/bruno/api_v1/product/Product Variants List.yml b/bruno/api_v1/product/Product Variants List.yml index e6edf3a..47be24b 100644 --- a/bruno/api_v1/product/Product Variants List.yml +++ b/bruno/api_v1/product/Product Variants List.yml @@ -13,7 +13,7 @@ http: runtime: variables: - name: product_id - value: "2361" + value: "51" settings: encodeUrl: true diff --git a/bruno/api_v1/product/Products List.yml b/bruno/api_v1/product/Products List.yml index 6763495..01983e2 100644 --- a/bruno/api_v1/product/Products List.yml +++ b/bruno/api_v1/product/Products List.yml @@ -5,7 +5,7 @@ info: http: method: GET - url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&category_id_in=243&reference=~62" + url: "{{bas_url}}/restricted/product/list?p=1&elems=30&sort=product_id,asc&reference=~NC100" params: - name: p value: "1" @@ -19,8 +19,9 @@ http: - name: category_id_in value: "243" type: query + disabled: true - name: reference - value: ~62 + value: ~NC100 type: query body: type: json diff --git a/bruno/api_v1/product/folder.yml b/bruno/api_v1/product/folder.yml index cd2ad8b..3957e49 100644 --- a/bruno/api_v1/product/folder.yml +++ b/bruno/api_v1/product/folder.yml @@ -1,7 +1,7 @@ info: name: product type: folder - seq: 7 + seq: 8 request: auth: inherit diff --git a/bruno/api_v1/specific_price/Activate.yml b/bruno/api_v1/specific_price/Activate.yml new file mode 100644 index 0000000..cf01772 --- /dev/null +++ b/bruno/api_v1/specific_price/Activate.yml @@ -0,0 +1,20 @@ +info: + name: Activate + type: http + seq: 5 + +http: + method: PATCH + url: "{{bas_url}}/restricted/specific-price/{{id}}/activate" + auth: inherit + +runtime: + variables: + - name: id + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/Create.yml b/bruno/api_v1/specific_price/Create.yml new file mode 100644 index 0000000..5ce143b --- /dev/null +++ b/bruno/api_v1/specific_price/Create.yml @@ -0,0 +1,27 @@ +info: + name: Create + type: http + seq: 3 + +http: + method: POST + url: "{{bas_url}}/restricted/specific-price" + body: + type: json + data: |- + { + "name": "Summer Sale 3", + "scope": "shop", + "reduction_type": "amount", + "price": 69, + "from_quantity": 1, + "is_active": true, + "currency_id": 2 + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/Deactivate.yml b/bruno/api_v1/specific_price/Deactivate.yml new file mode 100644 index 0000000..eea7b5a --- /dev/null +++ b/bruno/api_v1/specific_price/Deactivate.yml @@ -0,0 +1,20 @@ +info: + name: Deactivate + type: http + seq: 6 + +http: + method: PATCH + url: "{{bas_url}}/restricted/specific-price/{{id}}/deactivate" + auth: inherit + +runtime: + variables: + - name: id + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/Delete.yml b/bruno/api_v1/specific_price/Delete.yml new file mode 100644 index 0000000..5fe64ea --- /dev/null +++ b/bruno/api_v1/specific_price/Delete.yml @@ -0,0 +1,20 @@ +info: + name: Delete + type: http + seq: 7 + +http: + method: DELETE + url: "{{bas_url}}/restricted/price-reductions/{{id}}" + auth: inherit + +runtime: + variables: + - name: id + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/Get.yml b/bruno/api_v1/specific_price/Get.yml new file mode 100644 index 0000000..2e4ec71 --- /dev/null +++ b/bruno/api_v1/specific_price/Get.yml @@ -0,0 +1,20 @@ +info: + name: Get + type: http + seq: 2 + +http: + method: GET + url: "{{bas_url}}/restricted/specific-price/{{id}}" + auth: inherit + +runtime: + variables: + - name: id + value: "1" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/List.yml b/bruno/api_v1/specific_price/List.yml new file mode 100644 index 0000000..57a51c7 --- /dev/null +++ b/bruno/api_v1/specific_price/List.yml @@ -0,0 +1,15 @@ +info: + name: List + type: http + seq: 1 + +http: + method: GET + url: "{{bas_url}}/restricted/specific-price" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/Update.yml b/bruno/api_v1/specific_price/Update.yml new file mode 100644 index 0000000..82fa032 --- /dev/null +++ b/bruno/api_v1/specific_price/Update.yml @@ -0,0 +1,38 @@ +info: + name: Update + type: http + seq: 4 + +http: + method: PUT + url: "{{bas_url}}/restricted/specific-price/{{id}}" + body: + type: json + data: |- + { + "name": "Summer Sale Updated", + "reduction_type": "amount", + "percentage_reduction": 50.0, + "price": 69, + "currency_id": 1, + "scope": "shop", + "is_active": true, + "from_quantity":1 + // "product_ids": [51,53], + // "category_ids": [1], + // "product_attribute_ids": [1114], + // "country_ids": [1], + // "customer_ids": [2,1] + } + auth: inherit + +runtime: + variables: + - name: id + value: "3" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/api_v1/specific_price/folder.yml b/bruno/api_v1/specific_price/folder.yml new file mode 100644 index 0000000..92e0937 --- /dev/null +++ b/bruno/api_v1/specific_price/folder.yml @@ -0,0 +1,73 @@ +info: + name: specific_price + type: folder + seq: 3 + +docs: + content: | + # Specific Price API + + Endpoints for managing specific price rules (price reductions). + + ## Scopes + + Specific prices can be **global** or **scoped**: + + - **Global**: If all scope arrays (`product_ids`, `category_ids`, `product_attribute_ids`, `country_ids`, `customer_ids`) are empty, the price reduction applies to everything. + + - **Scoped**: If ANY scope array has values, the price reduction applies only when ANY condition matches (UNION logic). + + ### Scope Fields + + | Field | Type | Description | + |-------|------|-------------| + | `product_ids` | uint[] | Specific products | + | `category_ids` | uint[] | Products in categories | + | `product_attribute_ids` | uint[] | Product variants (e.g., size, color) | + | `country_ids` | uint[] | Customers in countries | + | `customer_ids` | uint[] | Specific customers | + + ### Examples + + **Global** (applies to all products): + ```json + { + "name": "Global Sale", + "reduction_type": "percentage", + "percentage_reduction": 10, + "from_quantity": 1 + } + ``` + + **Scoped to specific products**: + ```json + { + "name": "Product Sale", + "reduction_type": "percentage", + "percentage_reduction": 20, + "product_ids": [1, 2, 3] + } + ``` + + **Scoped to category + country**: + ```json + { + "name": "Category Country Sale", + "reduction_type": "amount", + "price": 9.99, + "category_ids": [5], + "country_ids": [1] + } + ``` + + ## Reduction Types + + - `percentage`: Requires `percentage_reduction` (e.g., 10.5 = 10.5% off) + - `amount`: Requires `price` (fixed price after reduction) + + ## Validation + + - `reduction_type` is required and must be "percentage" or "amount" + - If `reduction_type` is "percentage", then `percentage_reduction` is required + - If `reduction_type` is "amount", then `price` is required + type: text/markdown diff --git a/i18n/migrations/20260319163200_procedures.sql b/i18n/migrations/20260319163200_procedures.sql index eb8a6af..4a6097a 100644 --- a/i18n/migrations/20260319163200_procedures.sql +++ b/i18n/migrations/20260319163200_procedures.sql @@ -1,695 +1,46 @@ -- +goose Up -DELIMITER // -DROP PROCEDURE IF EXISTS get_full_product -// -CREATE PROCEDURE get_full_product( - IN p_id_product INT UNSIGNED, - IN p_id_shop INT UNSIGNED, - IN p_id_lang INT UNSIGNED, - IN p_id_customer INT UNSIGNED, - IN b2b_id_country INT UNSIGNED, - IN p_quantity INT UNSIGNED -) -BEGIN -DECLARE v_tax_rate DECIMAL(10, 4) DEFAULT 0; -DECLARE p_id_currency DECIMAL(10, 4) DEFAULT 0; -SELECT - COALESCE(t.rate, 0.0000) 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 ON b2b_countries.id = b2b_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 = b2b_countries.ps_id_country -ORDER BY - tr.id_state DESC, - tr.zipcode_from != '' DESC, - tr.id_tax_rule DESC -LIMIT - 1; - -SELECT - b2b_currencies.ps_id_currency INTO p_id_currency -FROM - b2b_currencies - LEFT JOIN b2b_countries ON b2b_countries.b2b_id_currency = b2b_currencies.id -WHERE - b2b_countries.id = b2b_id_country -LIMIT 1; - -/* FINAL JSON */ -SELECT - JSON_OBJECT( - /* ================= PRODUCT ================= */ - 'id_product', - p.id_product, - 'reference', - p.reference, - 'name', - pl.name, - 'description', - pl.description, - 'short_description', - pl.description_short, - /* ================= PRICE ================= */ -'price', -JSON_OBJECT( - 'base', - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate), - - 'final_tax_excl', - ( - CASE - WHEN bsp.id IS NOT NULL THEN - CASE - /* FIXED PRICE */ - WHEN bsp.reduction_type = 'amount' THEN - ( - CASE - WHEN bsp.b2b_id_currency IS NULL THEN bsp.price - ELSE bsp.price * br_bsp.conversion_rate - END - ) - - /* PERCENTAGE */ - WHEN bsp.reduction_type = 'percentage' THEN - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - * (1 - bsp.percentage_reduction / 100) - - ELSE - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - END - - ELSE - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - END - ), - - 'final_tax_incl', - ( - ( - CASE - WHEN bsp.id IS NOT NULL THEN - CASE - WHEN bsp.reduction_type = 'amount' THEN - ( - CASE - WHEN bsp.b2b_id_currency IS NULL THEN bsp.price - ELSE bsp.price * br_bsp.conversion_rate - END - ) - - WHEN bsp.reduction_type = 'percentage' THEN - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - * (1 - bsp.percentage_reduction / 100) - - ELSE - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - END - ELSE - COALESCE(ps.price * r.conversion_rate, p.price * r.conversion_rate) - END - ) * (1 + v_tax_rate / 100) - ) -), - /* ================= META ================= */ - 'active', - COALESCE(ps.active, p.active), - 'visibility', - COALESCE(ps.visibility, p.visibility), - 'manufacturer', - m.name, - 'category', - cl.name, - /* ================= IMAGE ================= */ - 'cover_image', - JSON_OBJECT( - 'id', - i.id_image, - 'legend', - il.legend - ), - /* ================= FEATURES ================= */ - 'features', - ( - SELECT - JSON_ARRAYAGG( - JSON_OBJECT( - 'name', - fl.name, - 'value', - fvl.value - ) - ) - FROM - ps_feature_product fp - JOIN ps_feature_lang fl ON fl.id_feature = fp.id_feature - AND fl.id_lang = p_id_lang - JOIN ps_feature_value_lang fvl ON fvl.id_feature_value = fp.id_feature_value - AND fvl.id_lang = p_id_lang - WHERE - fp.id_product = p.id_product - ), - /* ================= COMBINATIONS ================= */ - 'combinations', - ( - SELECT - JSON_ARRAYAGG( - JSON_OBJECT( - 'id_product_attribute', - pa.id_product_attribute, - 'reference', - pa.reference, - 'price', - JSON_OBJECT( - 'impact', - COALESCE(pas.price, pa.price), - 'final_tax_excl', - ( - COALESCE(ps.price, p.price) + COALESCE(pas.price, pa.price) - ), - 'final_tax_incl', - ( - ( - COALESCE(ps.price, p.price) + COALESCE(pas.price, pa.price) - ) * (1 + v_tax_rate / 100) - ) - ), - 'stock', - IFNULL(sa.quantity, 0), - 'default_on', - pas.default_on, - /* ATTRIBUTES JSON */ - '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 - ), - /* IMAGES */ - 'images', - ( - SELECT - JSON_ARRAYAGG(img.id_image) - FROM - ps_product_attribute_image pai - JOIN ps_image img ON img.id_image = pai.id_image - WHERE - pai.id_product_attribute = pa.id_product_attribute - ) - ) - ) - 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 - 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 - WHERE - pa.id_product = p.id_product - ) - ) AS product_json -FROM - ps_product p - LEFT JOIN ps_product_shop ps ON ps.id_product = p.id_product - AND ps.id_shop = p_id_shop - LEFT JOIN ps_product_lang pl ON pl.id_product = p.id_product - AND pl.id_lang = p_id_lang - AND pl.id_shop = p_id_shop - LEFT JOIN ps_category_lang cl ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) - AND cl.id_lang = p_id_lang - AND cl.id_shop = p_id_shop - LEFT JOIN ps_manufacturer m ON m.id_manufacturer = p.id_manufacturer - LEFT JOIN ps_image i ON i.id_product = p.id_product - AND i.cover = 1 - LEFT JOIN ps_image_lang il ON il.id_image = i.id_image - AND il.id_lang = p_id_lang - /* SPECIFIC PRICE */ -LEFT JOIN ( - SELECT bsp.* - FROM b2b_specific_price bsp - - /* RELATIONS */ - 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 bsp.is_active = TRUE - - /* SCOPE MATCH */ - AND ( - /* PRODUCT */ - (bsp.scope = 'product' AND bsp_p.id_product = p_id_product) - - /* CATEGORY */ - OR ( - bsp.scope = 'category' - AND bsp_c.id_category IN ( - SELECT cp.id_category - FROM ps_category_product cp - WHERE cp.id_product = p_id_product - ) - ) - - /* SHOP (GLOBAL) */ - OR (bsp.scope = 'shop') - ) - - /* CUSTOMER MATCH */ -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 MATCH */ -AND ( - NOT EXISTS ( - SELECT 1 FROM b2b_specific_price_country ctry - WHERE ctry.b2b_specific_price_id = bsp.id - ) - OR EXISTS ( - SELECT 1 FROM b2b_specific_price_country ctry - WHERE ctry.b2b_specific_price_id = bsp.id - AND ctry.b2b_id_country = b2b_id_country - ) -) - - /* 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()) - ) - ) - - ORDER BY - /* 🔥 SCOPE PRIORITY */ - bsp.scope = 'product' DESC, - bsp.scope = 'category' DESC, - bsp.scope = 'shop' DESC, - - /* 🔥 CUSTOMER PRIORITY */ - ( - 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 - ) - ) DESC, - - /* 🔥 COUNTRY PRIORITY */ - ( - EXISTS ( - SELECT 1 FROM b2b_specific_price_country ctry - WHERE ctry.b2b_specific_price_id = bsp.id - AND ctry.b2b_id_country = b2b_id_country - ) - ) DESC, - - /* GLOBAL fallback (no restrictions) naturally goes last */ - - bsp.from_quantity DESC, - bsp.id DESC - - LIMIT 1 -) bsp ON 1=1 -LEFT JOIN b2b_currency_rates br_bsp - ON br_bsp.b2b_id_currency = bsp.b2b_id_currency - AND br_bsp.created_at = ( - SELECT MAX(created_at) - FROM b2b_currency_rates - WHERE b2b_id_currency = bsp.b2b_id_currency - ) - LEFT JOIN b2b_countries ON b2b_countries.id = b2b_id_country - LEFT JOIN b2b_currencies ON b2b_currencies.id = p_id_currency - LEFT JOIN b2b_currency_rates r ON r.b2b_id_currency = b2b_currencies.id - AND r.created_at = ( - SELECT - MAX(created_at) - FROM - b2b_currency_rates - WHERE - b2b_id_currency = b2b_currencies.id - ) -WHERE - p.id_product = p_id_product -LIMIT - 1; -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 +DROP FUNCTION IF EXISTS fn_product_price // +CREATE FUNCTION fn_product_price( + p_id_product INT UNSIGNED, + p_id_shop INT UNSIGNED, + p_id_customer INT UNSIGNED, + p_id_country INT UNSIGNED, + p_quantity INT UNSIGNED, + p_id_product_attribute INT UNSIGNED ) +RETURNS JSON +DETERMINISTIC +READS SQL DATA 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; + DECLARE v_base_raw DECIMAL(20,6); + DECLARE v_base DECIMAL(20,6); + DECLARE v_excl DECIMAL(20,6); + DECLARE v_incl DECIMAL(20,6); - -- ========================================================= - -- 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; + DECLARE v_specific_currency_id BIGINT; - -- ========================= - -- 1. TAX RATE - -- ========================= + DECLARE v_has_specific INT DEFAULT 0; + + -- currency + DECLARE v_target_currency BIGINT; + DECLARE v_target_rate DECIMAL(13,6) DEFAULT 1; + DECLARE v_specific_rate DECIMAL(13,6) DEFAULT 1; + + SET p_id_product_attribute = NULLIF(p_id_product_attribute, 0); + + -- ================= 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 + 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 @@ -699,215 +50,352 @@ BEGIN 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 + -- ================= TARGET CURRENCY ================= + SELECT c.b2b_id_currency + INTO v_target_currency 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 + LIMIT 1; + + -- latest target rate + SELECT r.conversion_rate + INTO v_target_rate + FROM b2b_currency_rates r + WHERE r.b2b_id_currency = v_target_currency ORDER BY r.created_at DESC LIMIT 1; - -- ========================= - -- 3. BASE PRICE - -- ========================= - SELECT COALESCE(ps.price, p.price) * v_currency_rate - INTO v_base_price + -- ================= BASE PRICE (RAW) ================= + SELECT + COALESCE(ps.price, p.price) + COALESCE(pas.price, 0) + INTO v_base_raw 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 + 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 = p_id_product_attribute + AND pas.id_shop = p_id_shop + WHERE p.id_product = p_id_product; + + -- convert base to target currency + SET v_base = v_base_raw * v_target_rate; + + -- ================= RULE SELECTION ================= + 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 + + WHERE bsp.is_active = 1 + AND bsp.from_quantity <= p_quantity + + -- intersection rules (unchanged) + AND ( + NOT EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id) + OR EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product = p_id_product) + ) + + AND ( + NOT EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id) + OR EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product_attribute = p_id_product_attribute) + ) + + AND ( + NOT EXISTS (SELECT 1 FROM b2b_specific_price_category x WHERE x.b2b_specific_price_id = bsp.id) + OR EXISTS ( + SELECT 1 FROM b2b_specific_price_category x + JOIN ps_category_product cp ON cp.id_category = x.id_category + WHERE x.b2b_specific_price_id = bsp.id AND cp.id_product = p_id_product + ) + ) + + AND ( + NOT EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id) + OR EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_customer = p_id_customer) + ) + + AND ( + NOT EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id) + OR EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_country = p_id_country) + ) + + ORDER BY + -- customer wins + (EXISTS (SELECT 1 FROM b2b_specific_price_customer x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_customer = p_id_customer)) DESC, + + -- attribute + (EXISTS (SELECT 1 FROM b2b_specific_price_product_attribute x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product_attribute = p_id_product_attribute)) DESC, + + -- product + (EXISTS (SELECT 1 FROM b2b_specific_price_product x WHERE x.b2b_specific_price_id = bsp.id AND x.id_product = p_id_product)) DESC, + + -- category + (EXISTS ( + SELECT 1 FROM b2b_specific_price_category x + JOIN ps_category_product cp ON cp.id_category = x.id_category + WHERE x.b2b_specific_price_id = bsp.id AND cp.id_product = p_id_product + )) DESC, + + -- country + (EXISTS (SELECT 1 FROM b2b_specific_price_country x WHERE x.b2b_specific_price_id = bsp.id AND x.b2b_id_country = p_id_country)) DESC, + + bsp.id DESC + 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; + -- ================= APPLY ================= + SET v_excl = v_base; 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; + -- convert specific price currency if needed + IF v_specific_currency_id IS NOT NULL AND v_specific_currency_id != v_target_currency THEN + + SELECT r.conversion_rate + INTO v_specific_rate + FROM b2b_currency_rates r + WHERE r.b2b_id_currency = v_specific_currency_id + ORDER BY r.created_at DESC + LIMIT 1; + + -- normalize → then convert to target + SET v_excl = (v_fixed_price / v_specific_rate) * v_target_rate; + ELSE - SET v_final_excl = v_fixed_price; -- assume already converted or pre-handled + SET v_excl = v_fixed_price; END IF; ELSEIF v_reduction_type = 'percentage' THEN - SET v_final_excl = v_base_price * (1 - v_percentage / 100); + SET v_excl = v_base * (1 - v_percentage / 100); END IF; END IF; - -- ========================= - -- 6. TAX - -- ========================= - SET v_final_incl = v_final_excl * (1 + v_tax_rate / 100); + SET v_incl = v_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; + RETURN JSON_OBJECT( + 'base', v_base, + 'final_tax_excl', v_excl, + 'final_tax_incl', v_incl, + 'tax_rate', v_tax_rate, + 'rate', v_target_rate + ); END // DELIMITER ; +DELIMITER // + +DROP PROCEDURE IF EXISTS get_product_variants // +CREATE PROCEDURE get_product_variants( + IN p_id_product INT, + IN p_id_shop INT, + IN p_id_lang INT, + IN p_id_customer INT, + IN p_id_country INT, + IN p_quantity INT +) +BEGIN + +SELECT + pa.id_product_attribute, + pa.reference, + + -- PRICE (computed once per row via correlated subquery) + CAST(JSON_UNQUOTE(JSON_EXTRACT( + fn_product_price( + p_id_product, + p_id_shop, + p_id_customer, + p_id_country, + p_quantity, + pa.id_product_attribute + ), + '$.base' + )) AS DECIMAL(20,6)) AS base_price, + + CAST(JSON_UNQUOTE(JSON_EXTRACT( + fn_product_price( + p_id_product, + p_id_shop, + p_id_customer, + p_id_country, + p_quantity, + pa.id_product_attribute + ), + '$.final_tax_excl' + )) AS DECIMAL(20,6)) AS price_tax_excl, + + CAST(JSON_UNQUOTE(JSON_EXTRACT( + fn_product_price( + p_id_product, + p_id_shop, + p_id_customer, + p_id_country, + p_quantity, + pa.id_product_attribute + ), + '$.final_tax_incl' + )) AS DECIMAL(20,6)) AS price_tax_incl, + + IFNULL(sa.quantity, 0) AS quantity, + + ( + 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 + +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 + +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, + IN p_id_shop INT, + IN p_id_customer INT, + IN p_id_country INT, + IN p_quantity INT +) +BEGIN + +SELECT fn_product_price( + p_id_product, + p_id_shop, + p_id_customer, + p_id_country, + p_quantity, + NULL +) AS price; + +END // + +DELIMITER ; + +DELIMITER // +DROP PROCEDURE IF EXISTS get_product_base // +CREATE PROCEDURE get_product_base( + IN p_id_product INT, + IN p_id_shop INT, + IN p_id_lang INT +) +BEGIN + SELECT + p.id_product AS id, -- matches view.Product.ID + + p.reference, + p.supplier_reference, + p.ean13, + p.upc, + p.isbn, + + -- Price related (basic) + p.price AS base_price, + p.wholesale_price, + p.unity, + p.unit_price_ratio, + + -- Stock & Availability + p.quantity, + p.minimal_quantity, + p.available_for_order, + p.available_date, + p.out_of_stock AS out_of_stock_behavior, -- 0=deny, 1=allow, 2=default + + -- Flags + COALESCE(ps.on_sale, 0) AS on_sale, + COALESCE(ps.show_price, 1) AS show_price, + p.condition, + p.is_virtual, + + -- Physical + p.weight, + p.width, + p.height, + p.depth, + p.additional_shipping_cost, + + -- Delivery + p.additional_delivery_times AS delivery_days, -- you can adjust if needed + + -- Status + COALESCE(ps.active, p.active) AS active, + COALESCE(ps.visibility, p.visibility) AS visibility, + p.indexed, + + -- Other useful + p.date_add, + p.date_upd, + + -- Language data + pl.name, + pl.description, + pl.description_short, + + -- Relations + m.name AS manufacturer, + cl.name AS category + + FROM ps_product p + LEFT JOIN ps_product_shop ps + ON ps.id_product = p.id_product + AND ps.id_shop = p_id_shop + LEFT JOIN ps_product_lang pl + ON pl.id_product = p.id_product + AND pl.id_lang = p_id_lang + AND pl.id_shop = p_id_shop + LEFT JOIN ps_category_lang cl + ON cl.id_category = COALESCE(ps.id_category_default, p.id_category_default) + AND cl.id_lang = p_id_lang + AND cl.id_shop = p_id_shop + LEFT JOIN ps_manufacturer m + ON m.id_manufacturer = p.id_manufacturer + + WHERE p.id_product = p_id_product + LIMIT 1; +END // +DELIMITER ; -- +goose Down -- 2.49.1 From 38cb07f3d452f13834b2e4cf9daa65e8b4e3639a Mon Sep 17 00:00:00 2001 From: Wiktor Date: Mon, 13 Apr 2026 14:21:22 +0200 Subject: [PATCH 3/3] chore: address pull request review issues --- app/delivery/middleware/permissions.go | 22 ++-- app/delivery/middleware/perms/permissions.go | 9 +- app/delivery/web/api/restricted/product.go | 8 +- .../web/api/restricted/specificPrice.go | 120 +++++++----------- app/model/product.go | 61 --------- app/model/specificPrice.go | 1 - .../specificPriceService.go | 12 -- app/utils/const_data/consts.go | 1 + app/utils/responseErrors/responseErrors.go | 1 - .../20260302163122_create_tables.sql | 11 +- .../20260302163123_create_tables_data.sql | 3 + i18n/migrations/20260320113729_stuff.sql | 9 -- 12 files changed, 74 insertions(+), 184 deletions(-) delete mode 100644 i18n/migrations/20260320113729_stuff.sql diff --git a/app/delivery/middleware/permissions.go b/app/delivery/middleware/permissions.go index 96ab057..9c2eb00 100644 --- a/app/delivery/middleware/permissions.go +++ b/app/delivery/middleware/permissions.go @@ -2,27 +2,27 @@ package middleware import ( "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware/perms" - "git.ma-al.com/goc_daniel/b2b/app/model" + "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/response" + "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" ) func Require(p perms.Permission) fiber.Handler { return func(c fiber.Ctx) error { - u := c.Locals("user") - if u == nil { - return c.SendStatus(fiber.StatusUnauthorized) - } - - user, ok := u.(*model.UserSession) + user, ok := localeExtractor.GetCustomer(c) if !ok { - return c.SendStatus(fiber.StatusInternalServerError) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } - for _, perm := range user.Permissions { - if perm == p { + for _, perm := range user.Role.Permissions { + if perm.Name == p { return c.Next() } } - return c.SendStatus(fiber.StatusForbidden) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrForbidden)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrForbidden))) } } diff --git a/app/delivery/middleware/perms/permissions.go b/app/delivery/middleware/perms/permissions.go index 7528921..69ab5df 100644 --- a/app/delivery/middleware/perms/permissions.go +++ b/app/delivery/middleware/perms/permissions.go @@ -3,8 +3,9 @@ package perms type Permission string const ( - UserReadAny Permission = "user.read.any" - UserWriteAny Permission = "user.write.any" - UserDeleteAny Permission = "user.delete.any" - CurrencyWrite Permission = "currency.write" + UserReadAny Permission = "user.read.any" + UserWriteAny Permission = "user.write.any" + UserDeleteAny Permission = "user.delete.any" + CurrencyWrite Permission = "currency.write" + SpecificPriceManage Permission = "specific_price.manage" ) diff --git a/app/delivery/web/api/restricted/product.go b/app/delivery/web/api/restricted/product.go index 0db9699..ea0e07f 100644 --- a/app/delivery/web/api/restricted/product.go +++ b/app/delivery/web/api/restricted/product.go @@ -4,7 +4,7 @@ import ( "strconv" "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/dbmodel" "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" @@ -82,7 +82,7 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error { } func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { - paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingListProducts) + paging, filters, err := query_params.ParseFilters[dbmodel.PsProduct](c, columnMappingListProducts) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -94,7 +94,7 @@ func (h *ProductsHandler) ListProducts(c fiber.Ctx) error { JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } - list, err := h.productService.Find(customer.LangID, customer.ID, paging, filters, customer, 1, constdata.SHOP_ID) + list, err := h.productService.Find(customer.LangID, customer.ID, paging, filters, customer, constdata.DEFAULT_PRODUCT_QUANTITY, constdata.SHOP_ID) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -176,7 +176,7 @@ func (h *ProductsHandler) ListProductVariants(c fiber.Ctx) error { 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) + list, err := h.productService.GetProductAttributes(customer.LangID, uint(productID), constdata.SHOP_ID, customer.ID, customer.CountryID, constdata.DEFAULT_PRODUCT_QUANTITY) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) diff --git a/app/delivery/web/api/restricted/specificPrice.go b/app/delivery/web/api/restricted/specificPrice.go index 5658b99..bece83d 100644 --- a/app/delivery/web/api/restricted/specificPrice.go +++ b/app/delivery/web/api/restricted/specificPrice.go @@ -2,11 +2,14 @@ package restricted import ( "strconv" - "time" "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/delivery/middleware" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/specificPriceService" + "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" + "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" + "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "github.com/gofiber/fiber/v3" ) @@ -27,13 +30,13 @@ func NewSpecificPriceHandler() *SpecificPriceHandler { func SpecificPriceHandlerRoutes(r fiber.Router) fiber.Router { handler := NewSpecificPriceHandler() - r.Post("/", handler.Create) - r.Put("/:id", handler.Update) - r.Delete("/:id", handler.Delete) - r.Get("/", handler.List) - r.Get("/:id", handler.GetByID) - r.Patch("/:id/activate", handler.Activate) - r.Patch("/:id/deactivate", handler.Deactivate) + r.Post("/", middleware.Require("specific_price.manage"), handler.Create) + r.Put("/:id", middleware.Require("specific_price.manage"), handler.Update) + r.Delete("/:id", middleware.Require("specific_price.manage"), handler.Delete) + r.Get("/", middleware.Require("specific_price.manage"), handler.List) + r.Get("/:id", middleware.Require("specific_price.manage"), handler.GetByID) + r.Patch("/:id/activate", middleware.Require("specific_price.manage"), handler.Activate) + r.Patch("/:id/deactivate", middleware.Require("specific_price.manage"), handler.Deactivate) return r } @@ -41,147 +44,116 @@ func SpecificPriceHandlerRoutes(r fiber.Router) fiber.Router { func (h *SpecificPriceHandler) Create(c fiber.Ctx) error { var pr model.SpecificPrice if err := c.Bind().Body(&pr); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } result, err := h.SpecificPriceService.Create(c.Context(), &pr) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.Status(fiber.StatusCreated).JSON(result) + return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) Update(c fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } var pr model.SpecificPrice if err := c.Bind().Body(&pr); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrInvalidBody)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody))) } result, err := h.SpecificPriceService.Update(c.Context(), id, &pr) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(result) + return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) List(c fiber.Ctx) error { result, err := h.SpecificPriceService.List(c.Context()) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(result) + return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) GetByID(c fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } result, err := h.SpecificPriceService.GetByID(c.Context(), id) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(result) + return c.JSON(response.Make(&result, 1, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) Activate(c fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } err = h.SpecificPriceService.SetActive(c.Context(), id, true) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(fiber.Map{ - "message": "price reduction activated", - }) + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) Deactivate(c fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } err = h.SpecificPriceService.SetActive(c.Context(), id, false) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(fiber.Map{ - "message": "price reduction deactivated", - }) + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) } func (h *SpecificPriceHandler) Delete(c fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody), - }) + return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) } err = h.SpecificPriceService.Delete(c.Context(), id) if err != nil { - return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{ - "error": responseErrors.GetErrorCode(c, err), - }) + return c.Status(responseErrors.GetErrorStatus(err)). + JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) } - return c.JSON(fiber.Map{ - "message": "specific price deleted", - }) -} - -func parseTime(s *string) *time.Time { - if s == nil { - return nil - } - t, err := time.Parse(time.RFC3339, *s) - if err != nil { - return nil - } - return &t + return c.JSON(response.Make(nullable.GetNil(""), 0, i18n.T_(c, response.Message_OK))) } diff --git a/app/model/product.go b/app/model/product.go index f2bb5f9..8c062a7 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -1,66 +1,5 @@ package model -// Product contains each and every column from the table ps_product. -type Product struct { - ProductID uint `gorm:"column:id_product;primaryKey" json:"product_id" form:"product_id"` - SupplierID uint `gorm:"column:id_supplier" json:"supplier_id" form:"supplier_id"` - ManufacturerID uint `gorm:"column:id_manufacturer" json:"manufacturer_id" form:"manufacturer_id"` - CategoryDefaultID uint `gorm:"column:id_category_default" json:"category_default_id" form:"category_default_id"` - ShopDefaultID uint `gorm:"column:id_shop_default" json:"shop_default_id" form:"shop_default_id"` - TaxRulesGroupID uint `gorm:"column:id_tax_rules_group" json:"tax_rules_group_id" form:"tax_rules_group_id"` - OnSale uint `gorm:"column:on_sale" json:"on_sale" form:"on_sale"` - OnlineOnly uint `gorm:"column:online_only" json:"online_only" form:"online_only"` - EAN13 string `gorm:"column:ean13;type:varchar(13)" json:"ean13" form:"ean13"` - ISBN string `gorm:"column:isbn;type:varchar(32)" json:"isbn" form:"isbn"` - UPC string `gorm:"column:upc;type:varchar(12)" json:"upc" form:"upc"` - EkoTax float32 `gorm:"column:eko_tax;type:decimal(20,6)" json:"eko_tax" form:"eko_tax"` - Quantity uint `gorm:"column:quantity" json:"quantity" form:"quantity"` - MinimalQuantity uint `gorm:"column:minimal_quantity" json:"minimal_quantity" form:"minimal_quantity"` - LowStockThreshold uint `gorm:"column:low_stock_threshold" json:"low_stock_threshold" form:"low_stock_threshold"` - LowStockAlert uint `gorm:"column:low_stock_alert" json:"low_stock_alert" form:"low_stock_alert"` - Price float32 `gorm:"column:price;type:decimal(20,6)" json:"price" form:"price"` - WholesalePrice float32 `gorm:"column:wholesale_price;type:decimal(20,6)" json:"wholesale_price" form:"wholesale_price"` - Unity string `gorm:"column:unity;type:varchar(255)" json:"unity" form:"unity"` - UnitPriceRatio float32 `gorm:"column:unit_price_ratio;type:decimal(20,6)" json:"unit_price_ratio" form:"unit_price_ratio"` - UnitID uint `gorm:"column:id_unit;primaryKey" json:"unit_id" form:"unit_id"` - AdditionalShippingCost float32 `gorm:"column:additional_shipping_cost;type:decimal(20,2)" json:"additional_shipping_cost" form:"additional_shipping_cost"` - Reference string `gorm:"column:reference;type:varchar(64)" json:"reference" form:"reference"` - SupplierReference string `gorm:"column:supplier_reference;type:varchar(64)" json:"supplier_reference" form:"supplier_reference"` - Location string `gorm:"column:location;type:varchar(64)" json:"location" form:"location"` - - Width float32 `gorm:"column:width;type:decimal(20,6)" json:"width" form:"width"` - Height float32 `gorm:"column:height;type:decimal(20,6)" json:"height" form:"height"` - Depth float32 `gorm:"column:depth;type:decimal(20,6)" json:"depth" form:"depth"` - Weight float32 `gorm:"column:weight;type:decimal(20,6)" json:"weight" form:"weight"` - OutOfStock uint `gorm:"column:out_of_stock" json:"out_of_stock" form:"out_of_stock"` - AdditionalDeliveryTimes uint `gorm:"column:additional_delivery_times" json:"additional_delivery_times" form:"additional_delivery_times"` - QuantityDiscount uint `gorm:"column:quantity_discount" json:"quantity_discount" form:"quantity_discount"` - Customizable uint `gorm:"column:customizable" json:"customizable" form:"customizable"` - UploadableFiles uint `gorm:"column:uploadable_files" json:"uploadable_files" form:"uploadable_files"` - TextFields uint `gorm:"column:text_fields" json:"text_fields" form:"text_fields"` - - Active uint `gorm:"column:active" json:"active" form:"active"` - RedirectType string `gorm:"column:redirect_type;type:enum('','404','301-product','302-product','301-category','302-category')" json:"redirect_type" form:"redirect_type"` - TypeRedirectedID int `gorm:"column:id_type_redirected" json:"type_redirected_id" form:"type_redirected_id"` - AvailableForOrder uint `gorm:"column:available_for_order" json:"available_for_order" form:"available_for_order"` - AvailableDate string `gorm:"column:available_date;type:date" json:"available_date" form:"available_date"` - ShowCondition uint `gorm:"column:show_condition" json:"show_condition" form:"show_condition"` - Condition string `gorm:"column:condition;type:enum('new','used','refurbished')" json:"condition" form:"condition"` - ShowPrice uint `gorm:"column:show_price" json:"show_price" form:"show_price"` - - Indexed uint `gorm:"column:indexed" json:"indexed" form:"indexed"` - Visibility string `gorm:"column:visibility;type:enum('both','catalog','search','none')" json:"visibility" form:"visibility"` - CacheIsPack uint `gorm:"column:cache_is_pack" json:"cache_is_pack" form:"cache_is_pack"` - CacheHasAttachments uint `gorm:"column:cache_has_attachments" json:"cache_has_attachments" form:"cache_has_attachments"` - IsVirtual uint `gorm:"column:is_virtual" json:"is_virtual" form:"is_virtual"` - CacheDefaultAttribute uint `gorm:"column:cache_default_attribute" json:"cache_default_attribute" form:"cache_default_attribute"` - DateAdd string `gorm:"column:date_add;type:datetime" json:"date_add" form:"date_add"` - DateUpd string `gorm:"column:date_upd;type:datetime" json:"date_upd" form:"date_upd"` - AdvancedStockManagement uint `gorm:"column:advanced_stock_management" json:"advanced_stock_management" form:"advanced_stock_management"` - PackStockType uint `gorm:"column:pack_stock_type" json:"pack_stock_type" form:"pack_stock_type"` - State uint `gorm:"column:state" json:"state" form:"state"` - 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"` diff --git a/app/model/specificPrice.go b/app/model/specificPrice.go index e602bd4..46746f2 100644 --- a/app/model/specificPrice.go +++ b/app/model/specificPrice.go @@ -5,7 +5,6 @@ import "time" type SpecificPrice struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` Name string `gorm:"type:varchar(255);not null" json:"name"` - Scope string `gorm:"type:varchar(20);not null" json:"scope"` ValidFrom *time.Time `gorm:"null" json:"valid_from"` ValidTill *time.Time `gorm:"null" json:"valid_till"` HasExpirationDate bool `gorm:"default:false" json:"has_expiration_date"` diff --git a/app/service/specificPriceService/specificPriceService.go b/app/service/specificPriceService/specificPriceService.go index 7044284..e22d855 100644 --- a/app/service/specificPriceService/specificPriceService.go +++ b/app/service/specificPriceService/specificPriceService.go @@ -23,12 +23,6 @@ func (s *SpecificPriceService) Create(ctx context.Context, pr *model.SpecificPri return nil, err } - if pr.Scope == "shop" && len(pr.ProductIDs) == 0 && len(pr.CategoryIDs) == 0 && len(pr.ProductAttributeIDs) == 0 && len(pr.CountryIDs) == 0 && len(pr.CustomerIDs) == 0 { - // pr.Scope = "global" - } else { - // pr.Scope = "scoped" - } - if err := s.specificPriceRepo.Create(ctx, pr); err != nil { return nil, err } @@ -51,12 +45,6 @@ func (s *SpecificPriceService) Update(ctx context.Context, id uint64, pr *model. pr.ID = id - if pr.Scope == "shop" && len(pr.ProductIDs) == 0 && len(pr.CategoryIDs) == 0 && len(pr.ProductAttributeIDs) == 0 && len(pr.CountryIDs) == 0 && len(pr.CustomerIDs) == 0 { - // pr.Scope = "global" - } else { - // pr.Scope = "scoped" - } - if err := s.specificPriceRepo.Update(ctx, pr); err != nil { return nil, err } diff --git a/app/utils/const_data/consts.go b/app/utils/const_data/consts.go index aa62f27..5633b4d 100644 --- a/app/utils/const_data/consts.go +++ b/app/utils/const_data/consts.go @@ -3,6 +3,7 @@ package constdata // PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads). const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$` const SHOP_ID = 1 +const DEFAULT_PRODUCT_QUANTITY = 1 const SHOP_DEFAULT_LANGUAGE = 1 const ADMIN_NOTIFICATION_LANGUAGE = 2 diff --git a/app/utils/responseErrors/responseErrors.go b/app/utils/responseErrors/responseErrors.go index 2099c02..f4607dc 100644 --- a/app/utils/responseErrors/responseErrors.go +++ b/app/utils/responseErrors/responseErrors.go @@ -285,7 +285,6 @@ func GetErrorStatus(err error) int { errors.Is(err, ErrInvalidReductionType), errors.Is(err, ErrPercentageRequired), errors.Is(err, ErrPriceRequired), - errors.Is(err, ErrJSONBody), errors.Is(err, ErrAccessDenied), errors.Is(err, ErrFolderDoesNotExist), errors.Is(err, ErrFileDoesNotExist), diff --git a/i18n/migrations/20260302163122_create_tables.sql b/i18n/migrations/20260302163122_create_tables.sql index ba4469a..c03014a 100644 --- a/i18n/migrations/20260302163122_create_tables.sql +++ b/i18n/migrations/20260302163122_create_tables.sql @@ -238,7 +238,6 @@ CREATE TABLE b2b_specific_price ( created_at DATETIME NULL, updated_at DATETIME NULL, deleted_at DATETIME NULL, - scope ENUM('shop', 'category', 'product') NOT NULL, valid_from DATETIME NULL, valid_till DATETIME NULL, has_expiration_date BOOLEAN DEFAULT FALSE, @@ -249,11 +248,9 @@ CREATE TABLE b2b_specific_price ( from_quantity INT UNSIGNED DEFAULT 1, is_active BOOLEAN DEFAULT TRUE ) ENGINE = InnoDB; -CREATE INDEX idx_b2b_scope ON b2b_specific_price(scope); CREATE INDEX idx_b2b_active_dates ON b2b_specific_price(is_active, valid_from, valid_till); CREATE INDEX idx_b2b_lookup ON b2b_specific_price ( - scope, is_active, from_quantity ); @@ -307,11 +304,11 @@ ON b2b_specific_price_category (id_category); CREATE INDEX idx_b2b_product_attribute_rel ON b2b_specific_price_product_attribute (id_product_attribute); -CREATE INDEX idx_bsp_customer -ON b2b_specific_price_customer (b2b_specific_price_id, b2b_id_customer); +CREATE INDEX idx_bsp_customer_rel +ON b2b_specific_price_customer (b2b_id_customer); -CREATE INDEX idx_bsp_country -ON b2b_specific_price_country (b2b_specific_price_id, b2b_id_country); +CREATE INDEX idx_bsp_country_rel +ON b2b_specific_price_country (b2b_id_country); DELIMITER // diff --git a/i18n/migrations/20260302163123_create_tables_data.sql b/i18n/migrations/20260302163123_create_tables_data.sql index dafebf7..bb7fde3 100644 --- a/i18n/migrations/20260302163123_create_tables_data.sql +++ b/i18n/migrations/20260302163123_create_tables_data.sql @@ -34,13 +34,16 @@ INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('1', 'user.read.any'); INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('2', 'user.write.any'); INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('3', 'user.delete.any'); INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('4', 'currency.write'); +INSERT INTO `b2b_permissions` (`id`, `name`) VALUES ('5', 'specific_price.manage'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '1'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '2'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '3'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '4'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('2', '5'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '1'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '2'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '3'); INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '4'); +INSERT INTO `b2b_role_permissions` (`role_id`, `permission_id`) VALUES ('3', '5'); -- +goose Down \ No newline at end of file diff --git a/i18n/migrations/20260320113729_stuff.sql b/i18n/migrations/20260320113729_stuff.sql deleted file mode 100644 index b9c449e..0000000 --- a/i18n/migrations/20260320113729_stuff.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- +goose StatementBegin -SELECT 'up SQL query'; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -SELECT 'down SQL query'; --- +goose StatementEnd -- 2.49.1