feat: create specific price system and adapt product queries

This commit is contained in:
2026-04-10 14:49:05 +02:00
parent 75af44b0df
commit 54608410ea
34 changed files with 1449 additions and 939 deletions

View File

@@ -70,7 +70,7 @@ func (h *ProductsHandler) GetProductJson(c fiber.Ctx) error {
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)). return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadAttribute)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute))) JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, responseErrors.ErrBadAttribute)))
} }
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 { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)). return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err)))

View File

@@ -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
}

View File

@@ -117,6 +117,9 @@ func (s *Server) Setup() error {
carts := s.restricted.Group("/carts") carts := s.restricted.Group("/carts")
restricted.CartsHandlerRoutes(carts) restricted.CartsHandlerRoutes(carts)
specificPrice := s.restricted.Group("/specific-price")
restricted.SpecificPriceHandlerRoutes(specificPrice)
restricted.CurrencyHandlerRoutes(s.restricted) restricted.CurrencyHandlerRoutes(s.restricted)
s.api.All("*", func(c fiber.Ctx) error { s.api.All("*", func(c fiber.Ctx) error {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)

View File

@@ -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"
}

View File

@@ -2,7 +2,6 @@ package productsRepo
import ( import (
"encoding/json" "encoding/json"
"fmt"
"git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/config"
"git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/db"
@@ -16,9 +15,12 @@ import (
) )
type UIProductsRepo interface { type UIProductsRepo interface {
GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error) // GetJSON(p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity int) (*json.RawMessage, error)
Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) Find(id_lang uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error)
GetProductVariants(langID uint, productID uint, shopID uint, customerID uint, countryID uint, quantity uint) ([]view.ProductAttribute, error) 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{} type ProductsRepo struct{}
@@ -27,25 +29,75 @@ func New() UIProductsRepo {
return &ProductsRepo{} 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) { func (repo *ProductsRepo) GetBase(p_id_product, p_id_shop, p_id_lang uint) (view.Product, error) {
var productStr string // ← Scan as string first var result view.Product
err := db.DB.Raw(`CALL get_full_product(?,?,?,?,?,?)`, err := db.DB.Raw(`CALL get_product_base(?,?,?)`,
p_id_product, p_id_shop, p_id_lang, p_id_customer, b2b_id_country, p_quantity). p_id_product, p_id_shop, p_id_lang).
Scan(&productStr). Scan(&result).Error
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 { if err != nil {
return nil, err return view.Price{}, err
} }
// Optional: validate it's valid JSON var temp struct {
if !json.Valid([]byte(productStr)) { Base json.Number `json:"base"`
return nil, fmt.Errorf("invalid json returned from stored procedure") 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) if err := json.Unmarshal(r.Price, &temp); err != nil {
return &raw, 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) { func (repo *ProductsRepo) Find(langID uint, p find.Paging, filt *filters.FiltersList) (*find.Found[model.ProductInList], error) {

View File

@@ -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
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"git.ma-al.com/goc_daniel/b2b/app/db"
"git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/model"
"git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo" "git.ma-al.com/goc_daniel/b2b/app/repos/productsRepo"
constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data"
@@ -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) { func (s *ProductService) Get(
products, err := s.productsRepo.GetJSON(p_id_product, constdata.SHOP_ID, p_id_lang, p_id_customer, b2b_id_country, p_quantity) p_id_product, p_id_lang, p_id_customer, b2b_id_country, p_quantity uint,
if err != nil { ) (*json.RawMessage, error) {
return products, err
}
return products, nil product, err := s.productsRepo.GetBase(p_id_product, constdata.SHOP_ID, p_id_lang)
}
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 { if err != nil {
return nil, err 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 { for i := range found.Items {
if found.Items[i].VariantsNumber <= 0 { if found.Items[i].VariantsNumber <= 0 {
err := s.PopulateProductPrice(&found.Items[i], customer, quantity, shopID) simpleProductIndexes = append(simpleProductIndexes, i)
}
}
// 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 { if err != nil {
return nil, err return nil, err
} }
}
found.Items[i].PriceTaxExcl = price.FinalTaxExcl
found.Items[i].PriceTaxIncl = price.FinalTaxIncl
} }
return found, err return found, nil
}
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( func (s *ProductService) GetProductAttributes(
@@ -91,5 +116,10 @@ func (s *ProductService) GetProductAttributes(
countryID uint, countryID uint,
quantity uint, quantity uint,
) ([]view.ProductAttribute, error) { ) ([]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
} }

View File

@@ -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
}

View File

@@ -62,6 +62,12 @@ var (
ErrUserHasNoSuchCart = errors.New("user does not have cart with given id") ErrUserHasNoSuchCart = errors.New("user does not have cart with given id")
ErrProductOrItsVariationDoesNotExist = errors.New("product or its variation with given ids does not exist") 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 // Typed errors for data parsing
ErrJSONBody = errors.New("invalid JSON body") ErrJSONBody = errors.New("invalid JSON body")
) )
@@ -171,6 +177,15 @@ func GetErrorCode(c fiber.Ctx, err error) string {
case errors.Is(err, ErrProductOrItsVariationDoesNotExist): case errors.Is(err, ErrProductOrItsVariationDoesNotExist):
return i18n.T_(c, "error.product_or_its_variation_does_not_exist") 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): case errors.Is(err, ErrJSONBody):
return i18n.T_(c, "error.err_json_body") return i18n.T_(c, "error.err_json_body")
@@ -219,8 +234,13 @@ func GetErrorStatus(err error) int {
errors.Is(err, ErrMaxAmtOfCartsReached), errors.Is(err, ErrMaxAmtOfCartsReached),
errors.Is(err, ErrUserHasNoSuchCart), errors.Is(err, ErrUserHasNoSuchCart),
errors.Is(err, ErrProductOrItsVariationDoesNotExist), errors.Is(err, ErrProductOrItsVariationDoesNotExist),
errors.Is(err, ErrInvalidReductionType),
errors.Is(err, ErrPercentageRequired),
errors.Is(err, ErrPriceRequired),
errors.Is(err, ErrJSONBody): errors.Is(err, ErrJSONBody):
return fiber.StatusBadRequest return fiber.StatusBadRequest
case errors.Is(err, ErrSpecificPriceNotFound):
return fiber.StatusNotFound
case errors.Is(err, ErrEmailExists): case errors.Is(err, ErrEmailExists):
return fiber.StatusConflict return fiber.StatusConflict
case errors.Is(err, ErrAIResponseFail), case errors.Is(err, ErrAIResponseFail),

View File

@@ -1,9 +1,12 @@
package view package view
import "encoding/json" import (
"encoding/json"
"time"
)
type ProductAttribute struct { 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"` Reference string `gorm:"column:reference" json:"reference"`
BasePrice float64 `gorm:"column:base_price" json:"base_price"` BasePrice float64 `gorm:"column:base_price" json:"base_price"`
PriceTaxExcl float64 `gorm:"column:price_tax_excl" json:"price_tax_excl"` 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"` Quantity int64 `gorm:"column:quantity" json:"quantity"`
Attributes json.RawMessage `gorm:"column:attributes" json:"attributes"` 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"`
}

View File

@@ -1,7 +1,7 @@
info: info:
name: Change Locales name: Change Locales
type: http type: http
seq: 3 seq: 5
http: http:
method: POST method: POST

View File

@@ -1,7 +1,7 @@
info: info:
name: Delete Index - MeiliSearch name: Delete Index - MeiliSearch
type: http type: http
seq: 5 seq: 7
http: http:
method: DELETE method: DELETE

View File

@@ -1,7 +1,7 @@
info: info:
name: Search Index Settings name: Search Index Settings
type: http type: http
seq: 4 seq: 6
http: http:
method: POST method: POST

View File

@@ -1,7 +1,7 @@
info: info:
name: Search Items name: Search Items
type: http type: http
seq: 2 seq: 4
http: http:
method: POST method: POST

View File

@@ -1,7 +1,7 @@
info: info:
name: auth name: auth
type: folder type: folder
seq: 6 seq: 2
request: request:
auth: inherit auth: inherit

View File

@@ -11,7 +11,7 @@ http:
data: |- data: |-
{ {
"b2b_id_currency" : 1, "b2b_id_currency" : 1,
"conversion_rate": 4.2 "conversion_rate": 3
} }
auth: inherit auth: inherit

View File

@@ -11,7 +11,7 @@ http:
runtime: runtime:
variables: variables:
- name: id - name: id
value: "1" value: "2"
settings: settings:
encodeUrl: true encodeUrl: true

View File

@@ -1,7 +1,7 @@
info: info:
name: currency name: currency
type: folder type: folder
seq: 8 seq: 9
request: request:
auth: inherit auth: inherit

View File

@@ -5,10 +5,10 @@ info:
http: http:
method: GET method: GET
url: "{{bas_url}}/restricted/customer?id=1" url: "{{bas_url}}/restricted/customer?id=2"
params: params:
- name: id - name: id
value: "1" value: "2"
type: query type: query
auth: inherit auth: inherit

View File

@@ -5,10 +5,10 @@ info:
http: http:
method: GET method: GET
url: "{{bas_url}}/restricted/customer/list?search=" url: "{{bas_url}}/restricted/customer/list?search=marek"
params: params:
- name: search - name: search
value: "" value: marek
type: query type: query
auth: inherit auth: inherit

View File

@@ -1,7 +1,7 @@
info: info:
name: customer name: customer
type: folder type: folder
seq: 9 seq: 10
request: request:
auth: inherit auth: inherit

View File

@@ -5,7 +5,7 @@ info:
http: http:
method: GET method: GET
url: "{{bas_url}}/restricted/product/200/1/5" url: "{{bas_url}}/restricted/product/51/1/7"
auth: inherit auth: inherit
settings: settings:

View File

@@ -13,7 +13,7 @@ http:
runtime: runtime:
variables: variables:
- name: product_id - name: product_id
value: "2361" value: "51"
settings: settings:
encodeUrl: true encodeUrl: true

View File

@@ -5,7 +5,7 @@ info:
http: http:
method: GET 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: params:
- name: p - name: p
value: "1" value: "1"
@@ -19,8 +19,9 @@ http:
- name: category_id_in - name: category_id_in
value: "243" value: "243"
type: query type: query
disabled: true
- name: reference - name: reference
value: ~62 value: ~NC100
type: query type: query
body: body:
type: json type: json

View File

@@ -1,7 +1,7 @@
info: info:
name: product name: product
type: folder type: folder
seq: 7 seq: 8
request: request:
auth: inherit auth: inherit

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff