From b72ab4ef3d2655fe24a36b9433a39fcb8a858964 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Sun, 10 Aug 2025 21:46:44 +0700 Subject: [PATCH] Update product receipe --- internal/app/app.go | 8 + internal/contract/product_recipe_contract.go | 18 + internal/entities/product_recipe.go | 36 ++ internal/handler/product_recipe_handler.go | 227 +++++++++ internal/handler/report_handler.go | 3 - internal/models/product_recipe.go | 55 ++ .../processor/product_recipe_processor.go | 241 +++++++++ .../repository/product_recipe_repository.go | 127 +++++ internal/router/router.go | 17 +- internal/service/product_recipe_service.go | 62 +++ internal/service/report_service.go | 10 +- ...0038_create_product_recipes_table.down.sql | 2 + ...000038_create_product_recipes_table.up.sql | 24 + product-recipe-postman-collection.json | 469 ++++++++++++++++++ 14 files changed, 1287 insertions(+), 12 deletions(-) create mode 100644 internal/contract/product_recipe_contract.go create mode 100644 internal/entities/product_recipe.go create mode 100644 internal/handler/product_recipe_handler.go create mode 100644 internal/models/product_recipe.go create mode 100644 internal/processor/product_recipe_processor.go create mode 100644 internal/repository/product_recipe_repository.go create mode 100644 internal/service/product_recipe_service.go create mode 100644 migrations/000038_create_product_recipes_table.down.sql create mode 100644 migrations/000038_create_product_recipes_table.up.sql create mode 100644 product-recipe-postman-collection.json diff --git a/internal/app/app.go b/internal/app/app.go index 3343e0b..632f667 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -79,6 +79,7 @@ func (a *App) Initialize(cfg *config.Config) error { validators.tableValidator, services.unitService, services.ingredientService, + services.productRecipeService, ) return nil @@ -144,6 +145,7 @@ type repositories struct { tableRepo repository.TableRepositoryInterface unitRepo *repository.UnitRepository ingredientRepo *repository.IngredientRepository + productRecipeRepo *repository.ProductRecipeRepository } func (a *App) initRepositories() *repositories { @@ -168,6 +170,7 @@ func (a *App) initRepositories() *repositories { tableRepo: repository.NewTableRepository(a.db), unitRepo: repository.NewUnitRepository(a.db), ingredientRepo: repository.NewIngredientRepository(a.db), + productRecipeRepo: repository.NewProductRecipeRepository(a.db), } } @@ -188,6 +191,7 @@ type processors struct { tableProcessor *processor.TableProcessor unitProcessor *processor.UnitProcessorImpl ingredientProcessor *processor.IngredientProcessorImpl + productRecipeProcessor *processor.ProductRecipeProcessorImpl fileClient processor.FileClient } @@ -211,6 +215,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), + productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), fileClient: fileClient, } } @@ -234,6 +239,7 @@ type services struct { tableService *service.TableServiceImpl unitService *service.UnitServiceImpl ingredientService *service.IngredientServiceImpl + productRecipeService *service.ProductRecipeServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -256,6 +262,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con tableService := service.NewTableService(processors.tableProcessor, transformer.NewTableTransformer()) unitService := service.NewUnitService(processors.unitProcessor) ingredientService := service.NewIngredientService(processors.ingredientProcessor) + productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor) return &services{ userService: service.NewUserService(processors.userProcessor), @@ -276,6 +283,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con tableService: tableService, unitService: unitService, ingredientService: ingredientService, + productRecipeService: productRecipeService, } } diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go new file mode 100644 index 0000000..92529cc --- /dev/null +++ b/internal/contract/product_recipe_contract.go @@ -0,0 +1,18 @@ +package contract + +import ( + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type ProductRecipeContract interface { + Create(request *models.CreateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + GetByProductID(productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByProductAndVariantID(productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByIngredientID(ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + Update(id uuid.UUID, request *models.UpdateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + Delete(id uuid.UUID, organizationID uuid.UUID) error + BulkCreate(recipes []models.CreateProductRecipeRequest, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) +} \ No newline at end of file diff --git a/internal/entities/product_recipe.go b/internal/entities/product_recipe.go new file mode 100644 index 0000000..47916c2 --- /dev/null +++ b/internal/entities/product_recipe.go @@ -0,0 +1,36 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ProductRecipe struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"` + VariantID *uuid.UUID `gorm:"type:uuid;index" json:"variant_id"` + IngredientID uuid.UUID `gorm:"type:uuid;not null;index" json:"ingredient_id"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // Relations + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + ProductVariant *ProductVariant `gorm:"foreignKey:VariantID" json:"product_variant,omitempty"` + Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` +} + +func (pr *ProductRecipe) BeforeCreate(tx *gorm.DB) error { + if pr.ID == uuid.Nil { + pr.ID = uuid.New() + } + return nil +} + +func (ProductRecipe) TableName() string { + return "product_recipes" +} \ No newline at end of file diff --git a/internal/handler/product_recipe_handler.go b/internal/handler/product_recipe_handler.go new file mode 100644 index 0000000..ad13a1c --- /dev/null +++ b/internal/handler/product_recipe_handler.go @@ -0,0 +1,227 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type ProductRecipeHandler struct { + productRecipeService service.ProductRecipeService +} + +func NewProductRecipeHandler(productRecipeService service.ProductRecipeService) *ProductRecipeHandler { + return &ProductRecipeHandler{ + productRecipeService: productRecipeService, + } +} + +func (h *ProductRecipeHandler) Create(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var request models.CreateProductRecipeRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Create -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Create") + return + } + + recipeResponse, err := h.productRecipeService.Create(ctx, contextInfo.OrganizationID, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Create -> Failed to create product recipe") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Create") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(recipeResponse), "ProductRecipeHandler::Create") +} + +func (h *ProductRecipeHandler) GetByID(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByID -> Invalid recipe ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid recipe ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByID") + return + } + + recipeResponse, err := h.productRecipeService.GetByID(ctx, id, contextInfo.OrganizationID) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByID -> Failed to get product recipe") + validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Product recipe not found") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(recipeResponse), "ProductRecipeHandler::GetByID") +} + +func (h *ProductRecipeHandler) GetByProductID(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + productIDStr := c.Param("product_id") + productID, err := uuid.Parse(productIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByProductID -> Invalid product ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByProductID") + return + } + + // Check if variant_id is provided + variantIDStr := c.Query("variant_id") + var variantID *uuid.UUID + if variantIDStr != "" { + parsed, err := uuid.Parse(variantIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByProductID -> Invalid variant ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid variant ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByProductID") + return + } + variantID = &parsed + } + + var recipes []*models.ProductRecipeResponse + if variantIDStr != "" { + // Get by product and variant ID + recipes, err = h.productRecipeService.GetByProductAndVariantID(ctx, productID, variantID, contextInfo.OrganizationID) + } else { + // Get by product ID only + recipes, err = h.productRecipeService.GetByProductID(ctx, productID, contextInfo.OrganizationID) + } + + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByProductID -> Failed to get product recipes") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByProductID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(recipes), "ProductRecipeHandler::GetByProductID") +} + +func (h *ProductRecipeHandler) GetByIngredientID(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + ingredientIDStr := c.Param("ingredient_id") + ingredientID, err := uuid.Parse(ingredientIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByIngredientID -> Invalid ingredient ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByIngredientID") + return + } + + recipes, err := h.productRecipeService.GetByIngredientID(ctx, ingredientID, contextInfo.OrganizationID) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::GetByIngredientID -> Failed to get product recipes") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::GetByIngredientID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(recipes), "ProductRecipeHandler::GetByIngredientID") +} + +func (h *ProductRecipeHandler) Update(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Update -> Invalid recipe ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid recipe ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Update") + return + } + + var request models.UpdateProductRecipeRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Update -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Update") + return + } + + recipeResponse, err := h.productRecipeService.Update(ctx, id, contextInfo.OrganizationID, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Update -> Failed to update product recipe") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Update") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(recipeResponse), "ProductRecipeHandler::Update") +} + +func (h *ProductRecipeHandler) Delete(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Delete -> Invalid recipe ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid recipe ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Delete") + return + } + + err = h.productRecipeService.Delete(ctx, id, contextInfo.OrganizationID) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::Delete -> Failed to delete product recipe") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::Delete") + return + } + + response := map[string]interface{}{ + "message": "Product recipe deleted successfully", + } + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "ProductRecipeHandler::Delete") +} + +func (h *ProductRecipeHandler) BulkCreate(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var request struct { + Recipes []models.CreateProductRecipeRequest `json:"recipes" validate:"required,min=1"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::BulkCreate -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::BulkCreate") + return + } + + recipes, err := h.productRecipeService.BulkCreate(ctx, contextInfo.OrganizationID, request.Recipes) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ProductRecipeHandler::BulkCreate -> Failed to bulk create product recipes") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductRecipeHandler::BulkCreate") + return + } + + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(recipes)) +} \ No newline at end of file diff --git a/internal/handler/report_handler.go b/internal/handler/report_handler.go index f84e88f..4c00c24 100644 --- a/internal/handler/report_handler.go +++ b/internal/handler/report_handler.go @@ -19,7 +19,6 @@ func NewReportHandler(reportService service.ReportService, userService UserServi return &ReportHandler{reportService: reportService, userService: userService} } -// GET /api/v1/outlets/:outlet_id/reports/daily-transaction.pdf?date=YYYY-MM-DD func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) { ctx := c.Request.Context() ci := appcontext.FromGinContext(ctx) @@ -32,11 +31,9 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) { } } - // Get user name for "Dicetak Oleh" user, err := h.userService.GetUserByID(ctx, ci.UserID) var genBy string if err != nil { - // Fallback to user ID if name cannot be retrieved genBy = ci.UserID.String() } else { genBy = user.Name diff --git a/internal/models/product_recipe.go b/internal/models/product_recipe.go new file mode 100644 index 0000000..15e96c3 --- /dev/null +++ b/internal/models/product_recipe.go @@ -0,0 +1,55 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ProductRecipe struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + VariantID *uuid.UUID `json:"variant_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Product *Product `json:"product,omitempty"` + ProductVariant *ProductVariant `json:"product_variant,omitempty"` + Ingredient *Ingredient `json:"ingredient,omitempty"` +} + +type CreateProductRecipeRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id" validate:"required"` + VariantID *uuid.UUID `json:"variant_id"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type UpdateProductRecipeRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + VariantID *uuid.UUID `json:"variant_id"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type ProductRecipeResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + VariantID *uuid.UUID `json:"variant_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Product *Product `json:"product,omitempty"` + ProductVariant *ProductVariant `json:"product_variant,omitempty"` + Ingredient *Ingredient `json:"ingredient,omitempty"` +} \ No newline at end of file diff --git a/internal/processor/product_recipe_processor.go b/internal/processor/product_recipe_processor.go new file mode 100644 index 0000000..7fc39d8 --- /dev/null +++ b/internal/processor/product_recipe_processor.go @@ -0,0 +1,241 @@ +package processor + +import ( + "apskel-pos-be/internal/constants" + "context" + "fmt" + "time" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + + "github.com/google/uuid" +) + +type ProductRecipeProcessor interface { + Create(ctx context.Context, req *models.CreateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + GetByID(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + GetByProductID(ctx context.Context, productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByIngredientID(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + Update(ctx context.Context, id uuid.UUID, req *models.UpdateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + Delete(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) error + BulkCreate(ctx context.Context, recipes []models.CreateProductRecipeRequest, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) +} + +type ProductRecipeProcessorImpl struct { + productRecipeRepo *repository.ProductRecipeRepository + productRepo ProductRepository + ingredientRepo IngredientRepository +} + +func NewProductRecipeProcessor(productRecipeRepo *repository.ProductRecipeRepository, productRepo ProductRepository, ingredientRepo IngredientRepository) *ProductRecipeProcessorImpl { + return &ProductRecipeProcessorImpl{ + productRecipeRepo: productRecipeRepo, + productRepo: productRepo, + ingredientRepo: ingredientRepo, + } +} + +func (p *ProductRecipeProcessorImpl) Create(ctx context.Context, req *models.CreateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) { + _, err := p.productRepo.GetByID(ctx, req.ProductID) + if err != nil { + return nil, fmt.Errorf("invalid product: %w", err) + } + + _, err = p.ingredientRepo.GetByID(ctx, req.IngredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("invalid ingredient: %w", err) + } + + entity := &entities.ProductRecipe{ + ID: uuid.New(), + OrganizationID: organizationID, + OutletID: req.OutletID, + ProductID: req.ProductID, + VariantID: req.VariantID, + IngredientID: req.IngredientID, + Quantity: req.Quantity, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + if err := p.productRecipeRepo.Create(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to create product recipe: %w", err) + } + + createdEntity, err := p.productRecipeRepo.GetByID(ctx, entity.ID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get created product recipe: %w", err) + } + + return p.entityToResponse(createdEntity), nil +} + +func (p *ProductRecipeProcessorImpl) GetByID(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) { + entity, err := p.productRecipeRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get product recipe: %w", err) + } + + return p.entityToResponse(entity), nil +} + +func (p *ProductRecipeProcessorImpl) GetByProductID(ctx context.Context, productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + entities, err := p.productRecipeRepo.GetByProductID(ctx, productID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get product recipes by product ID: %w", err) + } + + responses := make([]*models.ProductRecipeResponse, len(entities)) + for i, entity := range entities { + responses[i] = p.entityToResponse(entity) + } + + return responses, nil +} + +func (p *ProductRecipeProcessorImpl) GetByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + entities, err := p.productRecipeRepo.GetByProductAndVariantID(ctx, productID, variantID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get product recipes by product and variant ID: %w", err) + } + + responses := make([]*models.ProductRecipeResponse, len(entities)) + for i, entity := range entities { + responses[i] = p.entityToResponse(entity) + } + + return responses, nil +} + +func (p *ProductRecipeProcessorImpl) GetByIngredientID(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + entities, err := p.productRecipeRepo.GetByIngredientID(ctx, ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get product recipes by ingredient ID: %w", err) + } + + responses := make([]*models.ProductRecipeResponse, len(entities)) + for i, entity := range entities { + responses[i] = p.entityToResponse(entity) + } + + return responses, nil +} + +func (p *ProductRecipeProcessorImpl) Update(ctx context.Context, id uuid.UUID, req *models.UpdateProductRecipeRequest, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) { + // Get existing entity + existingEntity, err := p.productRecipeRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("product recipe not found: %w", err) + } + + // Update fields + existingEntity.OutletID = req.OutletID + existingEntity.VariantID = req.VariantID + existingEntity.Quantity = req.Quantity + existingEntity.UpdatedAt = time.Now().UTC() + + if err := p.productRecipeRepo.Update(ctx, existingEntity); err != nil { + return nil, fmt.Errorf("failed to update product recipe: %w", err) + } + + // Get the updated entity with relations + updatedEntity, err := p.productRecipeRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get updated product recipe: %w", err) + } + + return p.entityToResponse(updatedEntity), nil +} + +func (p *ProductRecipeProcessorImpl) Delete(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) error { + if err := p.productRecipeRepo.Delete(ctx, id, organizationID); err != nil { + return fmt.Errorf("failed to delete product recipe: %w", err) + } + + return nil +} + +func (p *ProductRecipeProcessorImpl) BulkCreate(ctx context.Context, recipes []models.CreateProductRecipeRequest, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + responses := make([]*models.ProductRecipeResponse, 0, len(recipes)) + + for _, recipe := range recipes { + response, err := p.Create(ctx, &recipe, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to create recipe for product %s and ingredient %s: %w", recipe.ProductID, recipe.IngredientID, err) + } + responses = append(responses, response) + } + + return responses, nil +} + +func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRecipe) *models.ProductRecipeResponse { + response := &models.ProductRecipeResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + ProductID: entity.ProductID, + VariantID: entity.VariantID, + IngredientID: entity.IngredientID, + Quantity: entity.Quantity, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } + + if entity.Product != nil { + response.Product = &models.Product{ + ID: entity.Product.ID, + OrganizationID: entity.Product.OrganizationID, + CategoryID: entity.Product.CategoryID, + SKU: entity.Product.SKU, + Name: entity.Product.Name, + Description: entity.Product.Description, + Price: entity.Product.Price, + Cost: entity.Product.Cost, + BusinessType: constants.BusinessType(entity.Product.BusinessType), + ImageURL: entity.Product.ImageURL, + PrinterType: entity.Product.PrinterType, + UnitID: entity.Product.UnitID, + HasIngredients: entity.Product.HasIngredients, + Metadata: entity.Product.Metadata, + IsActive: entity.Product.IsActive, + CreatedAt: entity.Product.CreatedAt, + UpdatedAt: entity.Product.UpdatedAt, + } + } + + if entity.ProductVariant != nil { + response.ProductVariant = &models.ProductVariant{ + ID: entity.ProductVariant.ID, + ProductID: entity.ProductVariant.ProductID, + Name: entity.ProductVariant.Name, + PriceModifier: entity.ProductVariant.PriceModifier, + Cost: entity.ProductVariant.Cost, + Metadata: entity.ProductVariant.Metadata, + CreatedAt: entity.ProductVariant.CreatedAt, + UpdatedAt: entity.ProductVariant.UpdatedAt, + } + } + + if entity.Ingredient != nil { + response.Ingredient = &models.Ingredient{ + ID: entity.Ingredient.ID, + OrganizationID: entity.Ingredient.OrganizationID, + OutletID: entity.Ingredient.OutletID, + Name: entity.Ingredient.Name, + UnitID: entity.Ingredient.UnitID, + Cost: entity.Ingredient.Cost, + Stock: entity.Ingredient.Stock, + IsSemiFinished: entity.Ingredient.IsSemiFinished, + IsActive: entity.Ingredient.IsActive, + Metadata: entity.Ingredient.Metadata, + CreatedAt: entity.Ingredient.CreatedAt, + UpdatedAt: entity.Ingredient.UpdatedAt, + } + } + + return response +} diff --git a/internal/repository/product_recipe_repository.go b/internal/repository/product_recipe_repository.go new file mode 100644 index 0000000..ca955c6 --- /dev/null +++ b/internal/repository/product_recipe_repository.go @@ -0,0 +1,127 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + + "gorm.io/gorm" + + "github.com/google/uuid" +) + +type ProductRecipeRepository struct { + db *gorm.DB +} + +func NewProductRecipeRepository(db *gorm.DB) *ProductRecipeRepository { + return &ProductRecipeRepository{db: db} +} + +func (r *ProductRecipeRepository) Create(ctx context.Context, productRecipe *entities.ProductRecipe) error { + return r.db.WithContext(ctx).Create(productRecipe).Error +} + +func (r *ProductRecipeRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductRecipe, error) { + var productRecipe entities.ProductRecipe + err := r.db.WithContext(ctx). + Preload("Product"). + Preload("ProductVariant"). + Preload("Ingredient"). + Where("id = ? AND organization_id = ?", id, organizationID). + First(&productRecipe).Error + if err != nil { + return nil, err + } + return &productRecipe, nil +} + +func (r *ProductRecipeRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductRecipe, error) { + var productRecipes []*entities.ProductRecipe + err := r.db.WithContext(ctx). + Preload("Product"). + Preload("ProductVariant"). + Preload("Ingredient"). + Where("product_id = ? AND organization_id = ?", productID, organizationID). + Order("created_at DESC"). + Find(&productRecipes).Error + if err != nil { + return nil, err + } + return productRecipes, nil +} + +func (r *ProductRecipeRepository) GetByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*entities.ProductRecipe, error) { + var productRecipes []*entities.ProductRecipe + query := r.db.WithContext(ctx). + Preload("Product"). + Preload("ProductVariant"). + Preload("Ingredient"). + Where("product_id = ? AND organization_id = ?", productID, organizationID) + + if variantID != nil { + query = query.Where("variant_id = ?", *variantID) + } else { + query = query.Where("variant_id IS NULL") + } + + err := query.Order("created_at DESC").Find(&productRecipes).Error + if err != nil { + return nil, err + } + return productRecipes, nil +} + +func (r *ProductRecipeRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductRecipe, error) { + var productRecipes []*entities.ProductRecipe + err := r.db.WithContext(ctx). + Preload("Product"). + Preload("ProductVariant"). + Preload("Ingredient"). + Where("ingredient_id = ? AND organization_id = ?", ingredientID, organizationID). + Order("created_at DESC"). + Find(&productRecipes).Error + if err != nil { + return nil, err + } + return productRecipes, nil +} + +func (r *ProductRecipeRepository) Update(ctx context.Context, productRecipe *entities.ProductRecipe) error { + return r.db.WithContext(ctx). + Where("id = ? AND organization_id = ?", productRecipe.ID, productRecipe.OrganizationID). + Updates(productRecipe).Error +} + +func (r *ProductRecipeRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + result := r.db.WithContext(ctx). + Where("id = ? AND organization_id = ?", id, organizationID). + Delete(&entities.ProductRecipe{}) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} + +func (r *ProductRecipeRepository) DeleteByProductID(ctx context.Context, productID, organizationID uuid.UUID) error { + return r.db.WithContext(ctx). + Where("product_id = ? AND organization_id = ?", productID, organizationID). + Delete(&entities.ProductRecipe{}).Error +} + +func (r *ProductRecipeRepository) DeleteByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) error { + query := r.db.WithContext(ctx).Where("product_id = ? AND organization_id = ?", productID, organizationID) + + if variantID != nil { + query = query.Where("variant_id = ?", *variantID) + } else { + query = query.Where("variant_id IS NULL") + } + + return query.Delete(&entities.ProductRecipe{}).Error +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 7205ed8..1149745 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -32,6 +32,7 @@ type Router struct { tableHandler *handler.TableHandler unitHandler *handler.UnitHandler ingredientHandler *handler.IngredientHandler + productRecipeHandler *handler.ProductRecipeHandler authMiddleware *middleware.AuthMiddleware } @@ -67,7 +68,8 @@ func NewRouter(cfg *config.Config, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, - ingredientService handler.IngredientService) *Router { + ingredientService handler.IngredientService, + productRecipeService service.ProductRecipeService) *Router { return &Router{ config: cfg, @@ -89,6 +91,7 @@ func NewRouter(cfg *config.Config, tableHandler: handler.NewTableHandler(tableService, tableValidator), unitHandler: handler.NewUnitHandler(unitService), ingredientHandler: handler.NewIngredientHandler(ingredientService), + productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService), authMiddleware: authMiddleware, } } @@ -289,6 +292,18 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { ingredients.DELETE("/:id", r.ingredientHandler.Delete) } + productRecipes := protected.Group("/product-recipes") + productRecipes.Use(r.authMiddleware.RequireAdminOrManager()) + { + productRecipes.POST("", r.productRecipeHandler.Create) + productRecipes.POST("/bulk", r.productRecipeHandler.BulkCreate) + productRecipes.GET("/:id", r.productRecipeHandler.GetByID) + productRecipes.PUT("/:id", r.productRecipeHandler.Update) + productRecipes.DELETE("/:id", r.productRecipeHandler.Delete) + productRecipes.GET("/product/:product_id", r.productRecipeHandler.GetByProductID) + productRecipes.GET("/ingredient/:ingredient_id", r.productRecipeHandler.GetByIngredientID) + } + outlets := protected.Group("/outlets") outlets.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/product_recipe_service.go b/internal/service/product_recipe_service.go new file mode 100644 index 0000000..63ef4f8 --- /dev/null +++ b/internal/service/product_recipe_service.go @@ -0,0 +1,62 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "context" + + "github.com/google/uuid" +) + +type ProductRecipeService interface { + Create(ctx context.Context, organizationID uuid.UUID, req *models.CreateProductRecipeRequest) (*models.ProductRecipeResponse, error) + GetByID(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) + GetByProductID(ctx context.Context, productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + GetByIngredientID(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) + Update(ctx context.Context, id uuid.UUID, organizationID uuid.UUID, req *models.UpdateProductRecipeRequest) (*models.ProductRecipeResponse, error) + Delete(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) error + BulkCreate(ctx context.Context, organizationID uuid.UUID, recipes []models.CreateProductRecipeRequest) ([]*models.ProductRecipeResponse, error) +} + +type ProductRecipeServiceImpl struct { + processor processor.ProductRecipeProcessor +} + +func NewProductRecipeService(processor processor.ProductRecipeProcessor) *ProductRecipeServiceImpl { + return &ProductRecipeServiceImpl{ + processor: processor, + } +} + +func (s *ProductRecipeServiceImpl) Create(ctx context.Context, organizationID uuid.UUID, req *models.CreateProductRecipeRequest) (*models.ProductRecipeResponse, error) { + return s.processor.Create(ctx, req, organizationID) +} + +func (s *ProductRecipeServiceImpl) GetByID(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) (*models.ProductRecipeResponse, error) { + return s.processor.GetByID(ctx, id, organizationID) +} + +func (s *ProductRecipeServiceImpl) GetByProductID(ctx context.Context, productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + return s.processor.GetByProductID(ctx, productID, organizationID) +} + +func (s *ProductRecipeServiceImpl) GetByProductAndVariantID(ctx context.Context, productID uuid.UUID, variantID *uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + return s.processor.GetByProductAndVariantID(ctx, productID, variantID, organizationID) +} + +func (s *ProductRecipeServiceImpl) GetByIngredientID(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductRecipeResponse, error) { + return s.processor.GetByIngredientID(ctx, ingredientID, organizationID) +} + +func (s *ProductRecipeServiceImpl) Update(ctx context.Context, id uuid.UUID, organizationID uuid.UUID, req *models.UpdateProductRecipeRequest) (*models.ProductRecipeResponse, error) { + return s.processor.Update(ctx, id, req, organizationID) +} + +func (s *ProductRecipeServiceImpl) Delete(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) error { + return s.processor.Delete(ctx, id, organizationID) +} + +func (s *ProductRecipeServiceImpl) BulkCreate(ctx context.Context, organizationID uuid.UUID, recipes []models.CreateProductRecipeRequest) ([]*models.ProductRecipeResponse, error) { + return s.processor.BulkCreate(ctx, recipes, organizationID) +} diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 6a497cb..55aeede 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -81,27 +81,26 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org return "", "", fmt.Errorf("invalid outlet id: %w", err) } - // Resolve organization and outlet names org, err := s.organizationRepo.GetByID(ctx, orgID) if err != nil { return "", "", fmt.Errorf("organization not found: %w", err) } + outlet, err := s.outletRepo.GetByID(ctx, outID) if err != nil { return "", "", fmt.Errorf("outlet not found: %w", err) } - // Determine timezone (fallback to system local if not available) tzName := "Asia/Jakarta" if outlet.Timezone != nil && *outlet.Timezone != "" { tzName = *outlet.Timezone } + loc, locErr := time.LoadLocation(tzName) if locErr != nil || loc == nil { loc = time.Local } - // Compute day range in the chosen location var day time.Time if reportDate != nil { t := reportDate.UTC() @@ -113,11 +112,9 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org start := day end := day.Add(24*time.Hour - time.Nanosecond) - // Build requests salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} - // Call services sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq) if err != nil { return "", "", fmt.Errorf("get sales analytics: %w", err) @@ -127,7 +124,6 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org return "", "", fmt.Errorf("get profit/loss analytics: %w", err) } - // Compose template data data := reportTemplateData{ OrganizationName: org.Name, OutletName: outlet.Name, @@ -149,7 +145,6 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org }, } - // Items by product items := make([]reportItem, 0, len(pl.ProductData)) for _, p := range pl.ProductData { items = append(items, reportItem{ @@ -164,7 +159,6 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org } data.Items = items - // Render to PDF templatePath := filepath.Join("templates", "daily_transaction.html") pdfBytes, err := renderTemplateToPDF(templatePath, data) if err != nil { diff --git a/migrations/000038_create_product_recipes_table.down.sql b/migrations/000038_create_product_recipes_table.down.sql new file mode 100644 index 0000000..0cc996d --- /dev/null +++ b/migrations/000038_create_product_recipes_table.down.sql @@ -0,0 +1,2 @@ +-- Drop the product_recipes table +DROP TABLE IF EXISTS product_recipes; \ No newline at end of file diff --git a/migrations/000038_create_product_recipes_table.up.sql b/migrations/000038_create_product_recipes_table.up.sql new file mode 100644 index 0000000..c375eb1 --- /dev/null +++ b/migrations/000038_create_product_recipes_table.up.sql @@ -0,0 +1,24 @@ +-- Product recipes table +CREATE TABLE product_recipes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID REFERENCES outlets(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + variant_id UUID REFERENCES product_variants(id) ON DELETE CASCADE, + ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + quantity DECIMAL(12,3) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_product_recipes_organization_id ON product_recipes(organization_id); +CREATE INDEX idx_product_recipes_outlet_id ON product_recipes(outlet_id); +CREATE INDEX idx_product_recipes_product_id ON product_recipes(product_id); +CREATE INDEX idx_product_recipes_variant_id ON product_recipes(variant_id); +CREATE INDEX idx_product_recipes_ingredient_id ON product_recipes(ingredient_id); +CREATE INDEX idx_product_recipes_created_at ON product_recipes(created_at); + +-- Unique constraint to prevent duplicate recipe combinations +-- This allows multiple recipes for same product with different variants, or same product with no variant +CREATE UNIQUE INDEX idx_product_recipes_unique ON product_recipes(product_id, COALESCE(variant_id, '00000000-0000-0000-0000-000000000000'::UUID), ingredient_id); \ No newline at end of file diff --git a/product-recipe-postman-collection.json b/product-recipe-postman-collection.json new file mode 100644 index 0000000..5791d57 --- /dev/null +++ b/product-recipe-postman-collection.json @@ -0,0 +1,469 @@ +{ + "info": { + "_postman_id": "product-recipe-collection", + "name": "Product Recipe API", + "description": "Complete CRUD operations for Product Recipe management", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "12345678" + }, + "item": [ + { + "name": "Authentication", + "item": [ + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@example.com\",\n \"password\": \"password123\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/auth/login", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "auth", + "login" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Product Recipe Management", + "item": [ + { + "name": "Create Product Recipe", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"variant_id\": \"{{variant_id}}\",\n \"ingredient_id\": \"{{ingredient_id}}\",\n \"quantity\": 2.5,\n \"outlet_id\": \"{{outlet_id}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/product-recipes", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes" + ] + } + }, + "response": [] + }, + { + "name": "Create Product Recipe (No Variant)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"ingredient_id\": \"{{ingredient_id}}\",\n \"quantity\": 1.0\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/product-recipes", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes" + ] + } + }, + "response": [] + }, + { + "name": "Bulk Create Product Recipes", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"recipes\": [\n {\n \"product_id\": \"{{product_id}}\",\n \"variant_id\": \"{{variant_id}}\",\n \"ingredient_id\": \"{{ingredient_id_1}}\",\n \"quantity\": 2.0\n },\n {\n \"product_id\": \"{{product_id}}\",\n \"variant_id\": \"{{variant_id}}\",\n \"ingredient_id\": \"{{ingredient_id_2}}\",\n \"quantity\": 1.5\n },\n {\n \"product_id\": \"{{product_id}}\",\n \"ingredient_id\": \"{{ingredient_id_3}}\",\n \"quantity\": 0.5\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/bulk", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Recipe by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/{{recipe_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "{{recipe_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Recipes by Product ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/product/{{product_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "product", + "{{product_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Recipes by Product ID and Variant ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/product/{{product_id}}?variant_id={{variant_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "product", + "{{product_id}}" + ], + "query": [ + { + "key": "variant_id", + "value": "{{variant_id}}" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Product Recipes by Ingredient ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/ingredient/{{ingredient_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "ingredient", + "{{ingredient_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Update Product Recipe", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 3.0,\n \"variant_id\": \"{{new_variant_id}}\",\n \"outlet_id\": \"{{outlet_id}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/{{recipe_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "{{recipe_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product Recipe", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/product-recipes/{{recipe_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "product-recipes", + "{{recipe_id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Supporting Data", + "item": [ + { + "name": "Get Products", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/products", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "Get Ingredients", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/ingredients", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "ingredients" + ] + } + }, + "response": [] + }, + { + "name": "Get Product by ID (with variants)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/products/{{product_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "products", + "{{product_id}}" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "access_token", + "value": "", + "type": "string" + }, + { + "key": "product_id", + "value": "", + "type": "string" + }, + { + "key": "variant_id", + "value": "", + "type": "string" + }, + { + "key": "ingredient_id", + "value": "", + "type": "string" + }, + { + "key": "ingredient_id_1", + "value": "", + "type": "string" + }, + { + "key": "ingredient_id_2", + "value": "", + "type": "string" + }, + { + "key": "ingredient_id_3", + "value": "", + "type": "string" + }, + { + "key": "recipe_id", + "value": "", + "type": "string" + }, + { + "key": "outlet_id", + "value": "", + "type": "string" + }, + { + "key": "new_variant_id", + "value": "", + "type": "string" + } + ] +} \ No newline at end of file