diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go index 979400f..afc2e7b 100644 --- a/internal/contract/product_contract.go +++ b/internal/contract/product_contract.go @@ -56,24 +56,26 @@ type UpdateProductVariantRequest struct { } type ProductResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - CategoryID uuid.UUID `json:"category_id"` - CategoryName string `json:"category_name"` - SKU *string `json:"sku"` - Name string `json:"name"` - Description *string `json:"description"` - Price float64 `json:"price"` - Cost float64 `json:"cost"` - BusinessType string `json:"business_type"` - ImageURL *string `json:"image_url"` - PrinterType string `json:"printer_type"` - Metadata map[string]interface{} `json:"metadata"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Category *CategoryResponse `json:"category,omitempty"` - Variants []ProductVariantResponse `json:"variants,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SKU *string `json:"sku"` + Name string `json:"name"` + Description *string `json:"description"` + Price float64 `json:"price"` + OutletPrice *float64 `json:"outlet_price,omitempty"` + OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"` + Cost float64 `json:"cost"` + BusinessType string `json:"business_type"` + ImageURL *string `json:"image_url"` + PrinterType string `json:"printer_type"` + Metadata map[string]interface{} `json:"metadata"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Category *CategoryResponse `json:"category,omitempty"` + Variants []ProductVariantResponse `json:"variants,omitempty"` } type ProductVariantResponse struct { diff --git a/internal/contract/product_outlet_price_contract.go b/internal/contract/product_outlet_price_contract.go index 27be75d..ed66de7 100644 --- a/internal/contract/product_outlet_price_contract.go +++ b/internal/contract/product_outlet_price_contract.go @@ -17,12 +17,13 @@ type UpdateProductOutletPriceRequest struct { } type ProductOutletPriceResponse struct { - ID uuid.UUID `json:"id"` - ProductID uuid.UUID `json:"product_id"` - OutletID uuid.UUID `json:"outlet_id"` - Price float64 `json:"price"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id,omitempty"` + ProductID uuid.UUID `json:"product_id,omitempty"` + OutletID uuid.UUID `json:"outlet_id"` + OutletName string `json:"outlet_name,omitempty"` + Price float64 `json:"price"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } type ListProductOutletPricesResponse struct { diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go index 9b18f27..ad389d9 100644 --- a/internal/handler/product_handler.go +++ b/internal/handler/product_handler.go @@ -117,6 +117,7 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) { func (h *ProductHandler) GetProduct(c *gin.Context) { ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) productIDStr := c.Param("id") productID, err := uuid.Parse(productIDStr) @@ -127,7 +128,7 @@ func (h *ProductHandler) GetProduct(c *gin.Context) { return } - productResponse := h.productService.GetProductByID(ctx, productID) + productResponse := h.productService.GetProductByID(ctx, productID, contextInfo.OutletID) if productResponse.HasErrors() { errorResp := productResponse.GetErrors()[0] logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service") diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index fb6c8ac..e91c3ef 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -135,6 +135,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse { Name: entity.Name, Description: entity.Description, Price: entity.Price, + OutletPrice: nil, // populated by processor when outletID is available Cost: entity.Cost, BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, diff --git a/internal/models/product.go b/internal/models/product.go index 43e47d2..beda4c3 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -100,6 +100,8 @@ type ProductResponse struct { Name string Description *string Price float64 + OutletPrice *float64 // outlet-specific price, nil if not set + OutletPrices []OutletPrice // all outlet prices, populated when no outletID in context Cost float64 BusinessType constants.BusinessType ImageURL *string @@ -113,6 +115,12 @@ type ProductResponse struct { Variants []ProductVariantResponse } +type OutletPrice struct { + OutletID uuid.UUID + OutletName string + Price float64 +} + type ProductVariantResponse struct { ID uuid.UUID ProductID uuid.UUID diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go index 2d7e568..3a9c9ea 100644 --- a/internal/processor/product_processor.go +++ b/internal/processor/product_processor.go @@ -16,7 +16,7 @@ type ProductProcessor interface { CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error) UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error) DeleteProduct(ctx context.Context, id uuid.UUID) error - GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error) + GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) } @@ -33,6 +33,7 @@ type ProductRepository interface { Update(ctx context.Context, product *entities.Product) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Product, int64, error) + ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error) Count(ctx context.Context, filters map[string]interface{}) (int64, error) GetBySKU(ctx context.Context, organizationID uuid.UUID, sku string) (*entities.Product, error) ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error) @@ -217,49 +218,65 @@ func (p *ProductProcessorImpl) DeleteProduct(ctx context.Context, id uuid.UUID) return nil } -func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error) { +func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) { productEntity, err := p.productRepo.GetWithCategory(ctx, id) if err != nil { return nil, fmt.Errorf("product not found: %w", err) } response := mappers.ProductEntityToResponse(productEntity) + + if outletID != uuid.Nil { + // Attach outlet-specific price + outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID) + if err == nil { + response.OutletPrice = &outletPrice.Price + } + } else { + // No outlet context — return all outlet prices for this product + outletPrices, err := p.outletPriceRepo.GetByProductWithOutlet(ctx, id) + if err == nil && len(outletPrices) > 0 { + prices := make([]models.OutletPrice, len(outletPrices)) + for i, op := range outletPrices { + prices[i] = models.OutletPrice{ + OutletID: op.OutletID, + OutletName: op.Outlet.Name, + Price: op.Price, + } + } + response.OutletPrices = prices + } + } + return response, nil } func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) { offset := (page - 1) * limit + // Extract outletID from filters — it's not a products column so remove it before querying var outletID uuid.UUID if oid, ok := filters["outlet_id"]; ok { outletID = oid.(uuid.UUID) delete(filters, "outlet_id") } - productEntities, total, err := p.productRepo.List(ctx, filters, limit, offset) + // Use the JOIN-based query when an outlet is specified so we get outlet-specific + // prices in a single round-trip; fall back to the plain List otherwise. + var ( + productEntities []*entities.Product + total int64 + err error + ) + if outletID != uuid.Nil { + productEntities, total, err = p.productRepo.ListWithOutletPrice(ctx, filters, outletID, limit, offset) + } else { + productEntities, total, err = p.productRepo.List(ctx, filters, limit, offset) + } if err != nil { return nil, 0, fmt.Errorf("failed to list products: %w", err) } - if outletID != uuid.Nil && len(productEntities) > 0 { - productIDs := make([]uuid.UUID, len(productEntities)) - for i, pe := range productEntities { - productIDs[i] = pe.ID - } - outletPrices, err := p.outletPriceRepo.GetByProductsAndOutlet(ctx, productIDs, outletID) - if err == nil { - priceMap := make(map[uuid.UUID]float64, len(outletPrices)) - for _, op := range outletPrices { - priceMap[op.ProductID] = op.Price - } - for _, pe := range productEntities { - if price, ok := priceMap[pe.ID]; ok { - pe.Price = price - } - } - } - } - responses := make([]models.ProductResponse, len(productEntities)) for i, entity := range productEntities { response := mappers.ProductEntityToResponse(entity) diff --git a/internal/repository/product_outlet_price_repository.go b/internal/repository/product_outlet_price_repository.go index 6c90a17..6a0f4aa 100644 --- a/internal/repository/product_outlet_price_repository.go +++ b/internal/repository/product_outlet_price_repository.go @@ -13,6 +13,7 @@ import ( type ProductOutletPriceRepository interface { GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*entities.ProductOutletPrice, error) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error) + GetByProductWithOutlet(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error) GetByProductsAndOutlet(ctx context.Context, productIDs []uuid.UUID, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error) Upsert(ctx context.Context, price *entities.ProductOutletPrice) error @@ -76,3 +77,9 @@ func (r *ProductOutletPriceRepositoryImpl) GetByProductsAndOutlet(ctx context.Co err := r.db.WithContext(ctx).Where("product_id IN ? AND outlet_id = ?", productIDs, outletID).Find(&prices).Error return prices, err } + +func (r *ProductOutletPriceRepositoryImpl) GetByProductWithOutlet(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error) { + var prices []*entities.ProductOutletPrice + err := r.db.WithContext(ctx).Preload("Outlet").Where("product_id = ?", productID).Find(&prices).Error + return prices, err +} diff --git a/internal/repository/product_repository.go b/internal/repository/product_repository.go index e0df5f1..d5fc5f4 100644 --- a/internal/repository/product_repository.go +++ b/internal/repository/product_repository.go @@ -189,3 +189,47 @@ func (r *ProductRepositoryImpl) GetLowCostProducts(ctx context.Context, organiza err := r.db.WithContext(ctx).Where("organization_id = ? AND cost <= ? AND is_active = ?", organizationID, maxCost, true).Find(&products).Error return products, err } + +// ListWithOutletPrice fetches products with the same filters as List, but overrides +// each product's Price with the outlet-specific price from product_outlet_prices when +// outletID is provided. A single LEFT JOIN is used so no second round-trip is needed. +func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error) { + var products []*entities.Product + var total int64 + + // Base query with category and variant preloads + query := r.db.WithContext(ctx).Model(&entities.Product{}). + Preload("Category"). + Preload("ProductVariants") + + // Apply filters + for key, value := range filters { + switch key { + case "search": + searchValue := "%" + value.(string) + "%" + query = query.Where("products.name ILIKE ? OR products.description ILIKE ? OR products.sku ILIKE ?", searchValue, searchValue, searchValue) + case "price_min": + query = query.Where("products.price >= ?", value) + case "price_max": + query = query.Where("products.price <= ?", value) + default: + query = query.Where("products."+key+" = ?", value) + } + } + + // When outletID is provided, INNER JOIN product_outlet_prices so only products + // that have been explicitly assigned to this outlet are returned, with their + // outlet-specific price. + if outletID != uuid.Nil { + query = query. + Joins("INNER JOIN product_outlet_prices pop ON pop.product_id = products.id AND pop.outlet_id = ?", outletID). + Select("products.*, pop.price AS price") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Limit(limit).Offset(offset).Find(&products).Error + return products, total, err +} diff --git a/internal/service/product_service.go b/internal/service/product_service.go index 38cd1e6..fce1eb6 100644 --- a/internal/service/product_service.go +++ b/internal/service/product_service.go @@ -16,7 +16,7 @@ type ProductService interface { CreateProduct(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *contract.Response UpdateProduct(ctx context.Context, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response DeleteProduct(ctx context.Context, id uuid.UUID) *contract.Response - GetProductByID(ctx context.Context, id uuid.UUID) *contract.Response + GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response ListProducts(ctx context.Context, req *contract.ListProductsRequest) *contract.Response ListProductsAll(ctx context.Context, req *contract.ListProductsRequest) *contract.Response } @@ -69,8 +69,8 @@ func (s *ProductServiceImpl) DeleteProduct(ctx context.Context, id uuid.UUID) *c }) } -func (s *ProductServiceImpl) GetProductByID(ctx context.Context, id uuid.UUID) *contract.Response { - productResponse, err := s.productProcessor.GetProductByID(ctx, id) +func (s *ProductServiceImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response { + productResponse, err := s.productProcessor.GetProductByID(ctx, id, outletID) if err != nil { errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductServiceEntity, err.Error()) return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go index 77ec925..be2b9fd 100644 --- a/internal/transformer/product_transformer.go +++ b/internal/transformer/product_transformer.go @@ -97,6 +97,19 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod } } + // Convert outlet prices + var outletPriceResponses []contract.ProductOutletPriceResponse + if len(prod.OutletPrices) > 0 { + outletPriceResponses = make([]contract.ProductOutletPriceResponse, len(prod.OutletPrices)) + for i, op := range prod.OutletPrices { + outletPriceResponses[i] = contract.ProductOutletPriceResponse{ + OutletID: op.OutletID, + OutletName: op.OutletName, + Price: op.Price, + } + } + } + return &contract.ProductResponse{ ID: prod.ID, OrganizationID: prod.OrganizationID, @@ -106,6 +119,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod Name: prod.Name, Description: prod.Description, Price: prod.Price, + OutletPrice: prod.OutletPrice, + OutletPrices: outletPriceResponses, Cost: prod.Cost, BusinessType: string(prod.BusinessType), ImageURL: prod.ImageURL,