From a7022dd4c1db62d874276c345967ffffb56c42ee Mon Sep 17 00:00:00 2001 From: Efril Date: Mon, 27 Apr 2026 21:17:12 +0700 Subject: [PATCH] ingredient composition --- internal/app/app.go | 4 +- internal/constants/error.go | 1 + .../ingredient_composition_contract.go | 16 - internal/entities/ingredient.go | 27 +- internal/entities/ingredient_composition.go | 20 +- internal/handler/ingredient_handler.go | 204 ++++++--- internal/handler/ingredient_service.go | 3 + .../mappers/ingredient_composition_mapper.go | 70 ---- internal/models/ingredient.go | 55 +-- internal/models/ingredient_composition.go | 57 ++- internal/processor/ingredient_processor.go | 389 +++++++++++------- internal/processor/product_processor_test.go | 180 -------- internal/processor/repository_interfaces.go | 9 + .../ingredient_composition_repository.go | 286 ++----------- internal/repository/ingredient_repository.go | 14 +- internal/router/router.go | 3 + internal/service/ingredient_processor.go | 3 + internal/service/ingredient_service.go | 12 + .../transformer/ingredient_transformer.go | 83 ++++ test_composition_endpoints.md | 123 ++++++ 20 files changed, 760 insertions(+), 799 deletions(-) delete mode 100644 internal/contract/ingredient_composition_contract.go delete mode 100644 internal/mappers/ingredient_composition_mapper.go delete mode 100644 internal/processor/product_processor_test.go create mode 100644 internal/transformer/ingredient_transformer.go create mode 100644 test_composition_endpoints.md diff --git a/internal/app/app.go b/internal/app/app.go index 28e93e6..0be0306 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -170,6 +170,7 @@ type repositories struct { tableRepo *repository.TableRepository unitRepo *repository.UnitRepository ingredientRepo *repository.IngredientRepository + ingredientCompositionRepo *repository.IngredientCompositionRepository productRecipeRepo *repository.ProductRecipeRepository vendorRepo *repository.VendorRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl @@ -215,6 +216,7 @@ func (a *App) initRepositories() *repositories { tableRepo: repository.NewTableRepository(a.db), unitRepo: repository.NewUnitRepository(a.db), ingredientRepo: repository.NewIngredientRepository(a.db), + ingredientCompositionRepo: repository.NewIngredientCompositionRepository(a.db), productRecipeRepo: repository.NewProductRecipeRepository(a.db), vendorRepo: repository.NewVendorRepositoryImpl(a.db), purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db), @@ -302,7 +304,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo), - ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), + ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), diff --git a/internal/constants/error.go b/internal/constants/error.go index cc8d499..002ac43 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -41,6 +41,7 @@ const ( VendorServiceEntity = "vendor_service" PurchaseOrderServiceEntity = "purchase_order_service" IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service" + IngredientCompositionServiceEntity = "ingredient_composition_service" TableEntity = "table" // Gamification entities CustomerPointsEntity = "customer_points" diff --git a/internal/contract/ingredient_composition_contract.go b/internal/contract/ingredient_composition_contract.go deleted file mode 100644 index 6c91bab..0000000 --- a/internal/contract/ingredient_composition_contract.go +++ /dev/null @@ -1,16 +0,0 @@ -package contract - -import ( - "apskel-pos-be/internal/models" - - "github.com/google/uuid" -) - -type IngredientCompositionContract interface { - Create(request *models.CreateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) - GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) - GetByParentIngredientID(parentIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error) - GetByChildIngredientID(childIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error) - Update(id uuid.UUID, request *models.UpdateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) - Delete(id uuid.UUID, organizationID uuid.UUID) error -} diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go index eadd025..d8e7f91 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -7,17 +7,18 @@ import ( ) type Ingredient 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"` - Name string `gorm:"not null;size:255" json:"name"` - UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` - Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` - Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` - IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` - IsActive bool `gorm:"default:true" json:"is_active"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Unit *Unit `gorm:"foreignKey:UnitID;references:ID" json:"unit,omitempty"` + 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"` + Name string `gorm:"not null;size:255" json:"name"` + UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` + Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` + IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` + IsActive bool `gorm:"default:true" json:"is_active"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + Unit *Unit `gorm:"foreignKey:UnitID;references:ID" json:"unit,omitempty"` + Compositions []IngredientComposition `gorm:"foreignKey:ParentIngredientID;references:ID" json:"compositions,omitempty"` } diff --git a/internal/entities/ingredient_composition.go b/internal/entities/ingredient_composition.go index fb3a0ff..0e84b5a 100644 --- a/internal/entities/ingredient_composition.go +++ b/internal/entities/ingredient_composition.go @@ -7,14 +7,14 @@ import ( ) type IngredientComposition struct { - ID uuid.UUID `json:"id" db:"id"` - OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` - ParentIngredientID uuid.UUID `json:"parent_ingredient_id" db:"parent_ingredient_id"` - ChildIngredientID uuid.UUID `json:"child_ingredient_id" db:"child_ingredient_id"` - Quantity float64 `json:"quantity" db:"quantity"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` - ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` + 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"` + ParentIngredientID uuid.UUID `gorm:"type:uuid;not null;index" json:"parent_ingredient_id"` + ChildIngredientID uuid.UUID `gorm:"type:uuid;not null;index" json:"child_ingredient_id"` + Quantity float64 `gorm:"type:decimal(10,4);not null" json:"quantity"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ParentIngredient *Ingredient `gorm:"foreignKey:ParentIngredientID;references:ID" json:"parent_ingredient,omitempty"` + ChildIngredient *Ingredient `gorm:"foreignKey:ChildIngredientID;references:ID" json:"child_ingredient,omitempty"` } diff --git a/internal/handler/ingredient_handler.go b/internal/handler/ingredient_handler.go index fbbdee2..9fb1ce1 100644 --- a/internal/handler/ingredient_handler.go +++ b/internal/handler/ingredient_handler.go @@ -18,9 +18,7 @@ type IngredientHandler struct { } func NewIngredientHandler(ingredientService IngredientService) *IngredientHandler { - return &IngredientHandler{ - ingredientService: ingredientService, - } + return &IngredientHandler{ingredientService: ingredientService} } func (h *IngredientHandler) Create(c *gin.Context) { @@ -29,53 +27,53 @@ func (h *IngredientHandler) Create(c *gin.Context) { var request models.CreateIngredientRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("IngredientHandler::Create -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> request binding failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::Create") return } request.OrganizationID = contextInfo.OrganizationID - ingredientResponse, err := h.ingredientService.CreateIngredient(ctx, &request) + resp, err := h.ingredientService.CreateIngredient(ctx, &request) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> Failed to create ingredient from service") - validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::Create") return } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Create") + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::Create") } func (h *IngredientHandler) GetByID(c *gin.Context) { ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) + id, err := uuid.Parse(c.Param("id")) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Invalid ingredient ID") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"), + }), "IngredientHandler::GetByID") return } - ingredientResponse, err := h.ingredientService.GetIngredientByID(ctx, id) + resp, err := h.ingredientService.GetIngredientByID(ctx, id) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Failed to get ingredient from service") - validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Ingredient not found") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Ingredient not found"), + }), "IngredientHandler::GetByID") return } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetByID") + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::GetByID") } func (h *IngredientHandler) GetAll(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) - // Get query parameters pageStr := c.DefaultQuery("page", "1") limitStr := c.DefaultQuery("limit", "10") search := c.Query("search") @@ -83,95 +81,177 @@ func (h *IngredientHandler) GetAll(c *gin.Context) { page, err := strconv.Atoi(pageStr) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid page parameter") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter"), + }), "IngredientHandler::GetAll") return } limit, err := strconv.Atoi(limitStr) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid limit parameter") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter"), + }), "IngredientHandler::GetAll") return } var outletID *uuid.UUID if outletIDStr != "" { - parsedOutletID, err := uuid.Parse(outletIDStr) + parsed, err := uuid.Parse(outletIDStr) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid outlet ID") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID"), + }), "IngredientHandler::GetAll") return } - outletID = &parsedOutletID + outletID = &parsed } - ingredientResponse, err := h.ingredientService.ListIngredients(ctx, contextInfo.OrganizationID, outletID, page, limit, search) + resp, err := h.ingredientService.ListIngredients(ctx, contextInfo.OrganizationID, outletID, page, limit, search) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Failed to get ingredients from service") - validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get ingredients") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get ingredients"), + }), "IngredientHandler::GetAll") return } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetAll") + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::GetAll") } func (h *IngredientHandler) Update(c *gin.Context) { ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) + id, err := uuid.Parse(c.Param("id")) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Invalid ingredient ID") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"), + }), "IngredientHandler::Update") return } var request models.UpdateIngredientRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body"), + }), "IngredientHandler::Update") return } - ingredientResponse, err := h.ingredientService.UpdateIngredient(ctx, id, &request) + resp, err := h.ingredientService.UpdateIngredient(ctx, id, &request) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Failed to update ingredient from service") - validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::Update") return } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Update") + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::Update") } func (h *IngredientHandler) Delete(c *gin.Context) { ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) + id, err := uuid.Parse(c.Param("id")) if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Invalid ingredient ID") - validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"), + }), "IngredientHandler::Delete") return } - err = h.ingredientService.DeleteIngredient(ctx, id) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Failed to delete ingredient from service") - validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete") + if err := h.ingredientService.DeleteIngredient(ctx, id); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::Delete") return } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]interface{}{ + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{ "message": "Ingredient deleted successfully", }), "IngredientHandler::Delete") } + +// AddCompositions adds multiple composition items to a semi-finished ingredient. +func (h *IngredientHandler) AddCompositions(c *gin.Context) { + ctx := c.Request.Context() + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid ingredient id"), + }), "IngredientHandler::AddCompositions") + return + } + + var req models.AddIngredientCompositionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::AddCompositions") + return + } + + resp, err := h.ingredientService.AddCompositions(ctx, id, &req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::AddCompositions") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::AddCompositions") +} + +// UpdateComposition updates quantity/outlet of a single composition entry. +func (h *IngredientHandler) UpdateComposition(c *gin.Context) { + ctx := c.Request.Context() + + id, err := uuid.Parse(c.Param("composition_id")) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid composition id"), + }), "IngredientHandler::UpdateComposition") + return + } + + var req models.UpdateIngredientCompositionRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::UpdateComposition") + return + } + + resp, err := h.ingredientService.UpdateComposition(ctx, id, &req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::UpdateComposition") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::UpdateComposition") +} + +// DeleteComposition removes a single composition entry. +func (h *IngredientHandler) DeleteComposition(c *gin.Context) { + ctx := c.Request.Context() + + id, err := uuid.Parse(c.Param("composition_id")) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid composition id"), + }), "IngredientHandler::DeleteComposition") + return + } + + resp, err := h.ingredientService.DeleteComposition(ctx, id) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "IngredientHandler::DeleteComposition") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::DeleteComposition") +} diff --git a/internal/handler/ingredient_service.go b/internal/handler/ingredient_service.go index 5405a3b..5056721 100644 --- a/internal/handler/ingredient_service.go +++ b/internal/handler/ingredient_service.go @@ -13,4 +13,7 @@ type IngredientService interface { DeleteIngredient(ctx context.Context, id uuid.UUID) error GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) + UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) + DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) + AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) } diff --git a/internal/mappers/ingredient_composition_mapper.go b/internal/mappers/ingredient_composition_mapper.go deleted file mode 100644 index 8b776d7..0000000 --- a/internal/mappers/ingredient_composition_mapper.go +++ /dev/null @@ -1,70 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -func MapIngredientCompositionEntityToModel(entity *entities.IngredientComposition) *models.IngredientComposition { - if entity == nil { - return nil - } - - return &models.IngredientComposition{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - ParentIngredientID: entity.ParentIngredientID, - ChildIngredientID: entity.ChildIngredientID, - Quantity: entity.Quantity, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - ParentIngredient: MapIngredientEntityToModel(entity.ParentIngredient), - ChildIngredient: MapIngredientEntityToModel(entity.ChildIngredient), - } -} - -func MapIngredientCompositionModelToEntity(model *models.IngredientComposition) *entities.IngredientComposition { - if model == nil { - return nil - } - - return &entities.IngredientComposition{ - ID: model.ID, - OrganizationID: model.OrganizationID, - OutletID: model.OutletID, - ParentIngredientID: model.ParentIngredientID, - ChildIngredientID: model.ChildIngredientID, - Quantity: model.Quantity, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - ParentIngredient: MapIngredientModelToEntity(model.ParentIngredient), - ChildIngredient: MapIngredientModelToEntity(model.ChildIngredient), - } -} - -func MapIngredientCompositionEntitiesToModels(entities []*entities.IngredientComposition) []*models.IngredientComposition { - if entities == nil { - return nil - } - - models := make([]*models.IngredientComposition, len(entities)) - for i, entity := range entities { - models[i] = MapIngredientCompositionEntityToModel(entity) - } - - return models -} - -func MapIngredientCompositionModelsToEntities(models []*models.IngredientComposition) []*entities.IngredientComposition { - if models == nil { - return nil - } - - entities := make([]*entities.IngredientComposition, len(models)) - for i, model := range models { - entities[i] = MapIngredientCompositionModelToEntity(model) - } - - return entities -} diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 232cb9c..7a3ac3d 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -26,15 +26,23 @@ type Ingredient struct { } type CreateIngredientRequest struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` - Cost float64 `json:"cost" validate:"min=0"` - Stock float64 `json:"stock" validate:"min=0"` - IsSemiFinished bool `json:"is_semi_finished"` - IsActive bool `json:"is_active"` - Metadata entities.Metadata `json:"metadata"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name" validate:"required,min=1,max=255"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Cost float64 `json:"cost" validate:"min=0"` + Stock float64 `json:"stock" validate:"min=0"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata entities.Metadata `json:"metadata"` + Compositions []CompositionItemRequest `json:"compositions,omitempty"` +} + +// CompositionItemRequest is used inside create ingredient request. +type CompositionItemRequest struct { + ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + OutletID *uuid.UUID `json:"outlet_id"` } type UpdateIngredientRequest struct { @@ -49,19 +57,18 @@ type UpdateIngredientRequest struct { } type IngredientResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` - Cost float64 `json:"cost"` - Stock float64 `json:"stock"` - IsSemiFinished bool `json:"is_semi_finished"` - IsActive bool `json:"is_active"` - Metadata entities.Metadata `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Relations - Unit *Unit `json:"unit,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + UnitID uuid.UUID `json:"unit_id"` + Cost float64 `json:"cost"` + Stock float64 `json:"stock"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata entities.Metadata `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Unit *Unit `json:"unit,omitempty"` + Compositions []*IngredientCompositionResponse `json:"compositions,omitempty"` } diff --git a/internal/models/ingredient_composition.go b/internal/models/ingredient_composition.go index 9b310d7..c33b7a4 100644 --- a/internal/models/ingredient_composition.go +++ b/internal/models/ingredient_composition.go @@ -6,44 +6,33 @@ import ( "github.com/google/uuid" ) -type IngredientComposition struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ParentIngredientID uuid.UUID `json:"parent_ingredient_id"` - ChildIngredientID uuid.UUID `json:"child_ingredient_id"` - Quantity float64 `json:"quantity"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Relations - ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` - ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` -} - -type CreateIngredientCompositionRequest struct { - OutletID *uuid.UUID `json:"outlet_id"` - ParentIngredientID uuid.UUID `json:"parent_ingredient_id" validate:"required"` - ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` -} - type UpdateIngredientCompositionRequest struct { OutletID *uuid.UUID `json:"outlet_id"` Quantity float64 `json:"quantity" validate:"required,gt=0"` } type IngredientCompositionResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ParentIngredientID uuid.UUID `json:"parent_ingredient_id"` - ChildIngredientID uuid.UUID `json:"child_ingredient_id"` - Quantity float64 `json:"quantity"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Relations - ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` - ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` + ID uuid.UUID `json:"id"` + OutletID *uuid.UUID `json:"outlet_id"` + ChildIngredientID uuid.UUID `json:"child_ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ChildIngredient *IngredientResponse `json:"child_ingredient,omitempty"` + ParentIngredient *IngredientResponse `json:"parent_ingredient,omitempty"` +} + +type CompositionItem struct { + ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + OutletID *uuid.UUID `json:"outlet_id"` +} + +type AddIngredientCompositionsRequest struct { + Compositions []CompositionItem `json:"compositions" validate:"required,min=1,dive"` +} + +type AddIngredientCompositionsResponse struct { + ParentIngredient *IngredientResponse `json:"parent_ingredient"` + Compositions []*IngredientCompositionResponse `json:"compositions"` } diff --git a/internal/processor/ingredient_processor.go b/internal/processor/ingredient_processor.go index 7655229..6a3ff3d 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -3,29 +3,31 @@ package processor import ( "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" + "apskel-pos-be/internal/transformer" "context" + "fmt" "time" "github.com/google/uuid" ) type IngredientProcessorImpl struct { - ingredientRepo IngredientRepository - unitRepo UnitRepository + ingredientRepo IngredientRepository + unitRepo UnitRepository + compositionRepo IngredientCompositionRepository } -func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository) *IngredientProcessorImpl { +func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository, compositionRepo IngredientCompositionRepository) *IngredientProcessorImpl { return &IngredientProcessorImpl{ - ingredientRepo: ingredientRepo, - unitRepo: unitRepo, + ingredientRepo: ingredientRepo, + unitRepo: unitRepo, + compositionRepo: compositionRepo, } } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { - _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID) - if err != nil { + if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { return nil, err } @@ -35,7 +37,7 @@ func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *mod OutletID: req.OutletID, Name: req.Name, UnitID: req.UnitID, - Cost: req.Cost, + Cost: req.Cost, // akan di-override oleh recalculateCost kalau semi-finished Stock: req.Stock, IsSemiFinished: req.IsSemiFinished, IsActive: req.IsActive, @@ -44,70 +46,30 @@ func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *mod UpdatedAt: time.Now(), } - err = p.ingredientRepo.Create(ctx, ingredient) - if err != nil { + if req.IsSemiFinished { + ingredient.Cost = 0 // dihitung dari compositions + } + + if err := p.ingredientRepo.Create(ctx, ingredient); err != nil { return nil, err } - ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, ingredient.ID, req.OrganizationID) - if err != nil { - return nil, err + // Save compositions if provided (only valid for semi-finished ingredients) + if req.IsSemiFinished && len(req.Compositions) > 0 { + if err := p.saveCompositions(ctx, ingredient.ID, req.OrganizationID, req.Compositions); err != nil { + return nil, err + } } - ingredientModel := mappers.MapIngredientEntityToModel(ingredientWithUnit) - response := &models.IngredientResponse{ - ID: ingredientModel.ID, - OrganizationID: ingredientModel.OrganizationID, - OutletID: ingredientModel.OutletID, - Name: ingredientModel.Name, - UnitID: ingredientModel.UnitID, - Cost: ingredientModel.Cost, - Stock: ingredientModel.Stock, - IsSemiFinished: ingredientModel.IsSemiFinished, - IsActive: ingredientModel.IsActive, - Metadata: ingredientModel.Metadata, - CreatedAt: ingredientModel.CreatedAt, - UpdatedAt: ingredientModel.UpdatedAt, - Unit: ingredientModel.Unit, - } - - return response, nil + return p.buildIngredientResponse(ctx, ingredient.ID, req.OrganizationID) } func (p *IngredientProcessorImpl) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { - contextInfo := appcontext.FromGinContext(ctx) - - // For now, we'll need to get organizationID from context or request - // This is a limitation of the current interface design - organizationID := contextInfo.OrganizationID // This should come from context - - ingredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID) - if err != nil { - return nil, err - } - - ingredientModel := mappers.MapIngredientEntityToModel(ingredient) - response := &models.IngredientResponse{ - ID: ingredientModel.ID, - OrganizationID: ingredientModel.OrganizationID, - OutletID: ingredientModel.OutletID, - Name: ingredientModel.Name, - UnitID: ingredientModel.UnitID, - Cost: ingredientModel.Cost, - Stock: ingredientModel.Stock, - IsSemiFinished: ingredientModel.IsSemiFinished, - IsActive: ingredientModel.IsActive, - Metadata: ingredientModel.Metadata, - CreatedAt: ingredientModel.CreatedAt, - UpdatedAt: ingredientModel.UpdatedAt, - Unit: ingredientModel.Unit, - } - - return response, nil + ctxInfo := appcontext.FromGinContext(ctx) + return p.buildIngredientResponse(ctx, id, ctxInfo.OrganizationID) } func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) { - // Set default values if page < 1 { page = 1 } @@ -123,30 +85,9 @@ func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizat return nil, err } - // Map to response models - ingredientModels := mappers.MapIngredientEntitiesToModels(ingredients) - ingredientResponses := make([]models.IngredientResponse, len(ingredientModels)) + ingredientResponses := transformer.IngredientsToResponses(ingredients) - for i, ingredientModel := range ingredientModels { - ingredientResponses[i] = models.IngredientResponse{ - ID: ingredientModel.ID, - OrganizationID: ingredientModel.OrganizationID, - OutletID: ingredientModel.OutletID, - Name: ingredientModel.Name, - UnitID: ingredientModel.UnitID, - Cost: ingredientModel.Cost, - Stock: ingredientModel.Stock, - IsSemiFinished: ingredientModel.IsSemiFinished, - IsActive: ingredientModel.IsActive, - Metadata: ingredientModel.Metadata, - CreatedAt: ingredientModel.CreatedAt, - UpdatedAt: ingredientModel.UpdatedAt, - Unit: ingredientModel.Unit, - } - } - - // Create paginated response - paginatedResponse := &models.PaginatedResponse[models.IngredientResponse]{ + return &models.PaginatedResponse[models.IngredientResponse]{ Data: ingredientResponses, Pagination: models.Pagination{ Page: page, @@ -154,86 +95,250 @@ func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizat Total: int64(total), TotalPages: (total + limit - 1) / limit, }, - } - - return paginatedResponse, nil + }, nil } func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) { - contextInfo := appcontext.FromGinContext(ctx) - // For now, we'll need to get organizationID from context or request - // This is a limitation of the current interface design - organizationID := contextInfo.OrganizationID // This should come from context + ctxInfo := appcontext.FromGinContext(ctx) + organizationID := ctxInfo.OrganizationID - // Get existing ingredient - existingIngredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID) + existing, err := p.ingredientRepo.GetByID(ctx, id, organizationID) if err != nil { return nil, err } - // Validate unit exists if changed - if req.UnitID != existingIngredient.UnitID { - _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID) - if err != nil { + if req.UnitID != existing.UnitID { + if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { return nil, err } } - // Update fields - if req.OutletID != nil { - existingIngredient.OutletID = req.OutletID + existing.OutletID = req.OutletID + existing.Name = req.Name + existing.UnitID = req.UnitID + existing.Stock = req.Stock + existing.IsSemiFinished = req.IsSemiFinished + existing.IsActive = req.IsActive + existing.Metadata = req.Metadata + existing.UpdatedAt = time.Now() + + // Cost hanya dipakai kalau bukan semi-finished + // Kalau semi-finished, cost dihitung dari compositions + if !req.IsSemiFinished { + existing.Cost = req.Cost } - existingIngredient.Name = req.Name - existingIngredient.UnitID = req.UnitID - existingIngredient.Cost = req.Cost - existingIngredient.Stock = req.Stock - existingIngredient.IsSemiFinished = req.IsSemiFinished - existingIngredient.IsActive = req.IsActive - existingIngredient.Metadata = req.Metadata - existingIngredient.UpdatedAt = time.Now() - - // Save to database - err = p.ingredientRepo.Update(ctx, existingIngredient) - if err != nil { + if err := p.ingredientRepo.Update(ctx, existing); err != nil { return nil, err } - // Get with relations - ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, id, organizationID) - if err != nil { - return nil, err - } - - // Map to response - ingredientModel := mappers.MapIngredientEntityToModel(ingredientWithUnit) - response := &models.IngredientResponse{ - ID: ingredientModel.ID, - OrganizationID: ingredientModel.OrganizationID, - OutletID: ingredientModel.OutletID, - Name: ingredientModel.Name, - UnitID: ingredientModel.UnitID, - Cost: ingredientModel.Cost, - Stock: ingredientModel.Stock, - IsSemiFinished: ingredientModel.IsSemiFinished, - IsActive: ingredientModel.IsActive, - Metadata: ingredientModel.Metadata, - CreatedAt: ingredientModel.CreatedAt, - UpdatedAt: ingredientModel.UpdatedAt, - Unit: ingredientModel.Unit, - } - - return response, nil + return p.buildIngredientResponse(ctx, id, organizationID) } func (p *IngredientProcessorImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error { - contextInfo := appcontext.FromGinContext(ctx) - organizationID := contextInfo.OrganizationID + ctxInfo := appcontext.FromGinContext(ctx) + return p.ingredientRepo.Delete(ctx, id, ctxInfo.OrganizationID) +} - err := p.ingredientRepo.Delete(ctx, id, organizationID) +func (p *IngredientProcessorImpl) AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) { + ctxInfo := appcontext.FromGinContext(ctx) + organizationID := ctxInfo.OrganizationID + + // Parent must exist and be semi-finished + parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID) + if err != nil { + return nil, fmt.Errorf("parent ingredient not found: %w", err) + } + if !parent.IsSemiFinished { + return nil, fmt.Errorf("parent ingredient must be marked as semi-finished") + } + + now := time.Now() + createdIDs := make([]uuid.UUID, 0, len(req.Compositions)) + + // Validate and create all compositions + for _, item := range req.Compositions { + // Child must exist + if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil { + return nil, fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err) + } + + if item.ChildIngredientID == parentID { + return nil, fmt.Errorf("child ingredient cannot be the same as parent") + } + + // Resolve outlet + outletID := item.OutletID + if outletID == nil && ctxInfo.OutletID != uuid.Nil { + outletID = &ctxInfo.OutletID + } + + compositionID := uuid.New() + composition := &entities.IngredientComposition{ + ID: compositionID, + OrganizationID: organizationID, + OutletID: outletID, + ParentIngredientID: parentID, + ChildIngredientID: item.ChildIngredientID, + Quantity: item.Quantity, + CreatedAt: now, + UpdatedAt: now, + } + + if err := p.compositionRepo.Create(ctx, composition); err != nil { + return nil, fmt.Errorf("failed to add composition: %w", err) + } + + createdIDs = append(createdIDs, compositionID) + } + + // Recalculate parent cost + if err := p.recalculateCost(ctx, parentID, organizationID); err != nil { + return nil, err + } + + // Get updated parent ingredient with all compositions + updatedParent, err := p.buildIngredientResponse(ctx, parentID, organizationID) + if err != nil { + return nil, err + } + + // Get created compositions with full details + createdCompositions := make([]*models.IngredientCompositionResponse, 0, len(createdIDs)) + for _, id := range createdIDs { + comp, err := p.compositionRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + createdCompositions = append(createdCompositions, transformer.CompositionEntityToResponse(comp)) + } + + return &models.AddIngredientCompositionsResponse{ + ParentIngredient: updatedParent, + Compositions: createdCompositions, + }, nil +} + +func (p *IngredientProcessorImpl) UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) { + ctxInfo := appcontext.FromGinContext(ctx) + + existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) + if err != nil { + return nil, fmt.Errorf("composition not found: %w", err) + } + + existing.Quantity = req.Quantity + existing.OutletID = req.OutletID + existing.UpdatedAt = time.Now() + + if err := p.compositionRepo.Update(ctx, existing); err != nil { + return nil, fmt.Errorf("failed to update composition: %w", err) + } + + // Recalculate parent cost since quantity changed + if err := p.recalculateCost(ctx, existing.ParentIngredientID, ctxInfo.OrganizationID); err != nil { + return nil, fmt.Errorf("failed to recalculate cost: %w", err) + } + + updated, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) + if err != nil { + return nil, err + } + + return transformer.CompositionEntityToResponse(updated), nil +} + +func (p *IngredientProcessorImpl) DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { + ctxInfo := appcontext.FromGinContext(ctx) + + existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) + if err != nil { + return nil, fmt.Errorf("composition not found: %w", err) + } + + parentID := existing.ParentIngredientID + + if err := p.compositionRepo.Delete(ctx, id, ctxInfo.OrganizationID); err != nil { + return nil, err + } + + // Recalculate parent cost after removal + if err := p.recalculateCost(ctx, parentID, ctxInfo.OrganizationID); err != nil { + return nil, err + } + + // Return updated parent ingredient + return p.buildIngredientResponse(ctx, parentID, ctxInfo.OrganizationID) +} + +// --- private helpers --- + +func (p *IngredientProcessorImpl) saveCompositions(ctx context.Context, parentID, organizationID uuid.UUID, items []models.CompositionItemRequest) error { + ctxInfo := appcontext.FromGinContext(ctx) + now := time.Now() + + for _, item := range items { + if item.ChildIngredientID == parentID { + return fmt.Errorf("child ingredient cannot be the same as parent") + } + if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil { + return fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err) + } + + // Resolve outlet: use item's outlet if provided, otherwise fall back to context outlet + outletID := item.OutletID + if outletID == nil && ctxInfo.OutletID != uuid.Nil { + outletID = &ctxInfo.OutletID + } + + composition := &entities.IngredientComposition{ + ID: uuid.New(), + OrganizationID: organizationID, + OutletID: outletID, + ParentIngredientID: parentID, + ChildIngredientID: item.ChildIngredientID, + Quantity: item.Quantity, + CreatedAt: now, + UpdatedAt: now, + } + if err := p.compositionRepo.Create(ctx, composition); err != nil { + return fmt.Errorf("failed to save composition: %w", err) + } + } + + // Recalculate cost of the semi-finished parent from child costs + return p.recalculateCost(ctx, parentID, organizationID) +} + +// recalculateCost sums up (child.cost * composition.quantity) and updates the parent ingredient cost. +func (p *IngredientProcessorImpl) recalculateCost(ctx context.Context, parentID, organizationID uuid.UUID) error { + compositions, err := p.compositionRepo.GetByParentIngredientID(ctx, parentID, organizationID) if err != nil { return err } - return nil + var totalCost float64 + for _, c := range compositions { + if c.ChildIngredient != nil { + totalCost += c.ChildIngredient.Cost * c.Quantity + } + } + + parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID) + if err != nil { + return err + } + + parent.Cost = totalCost + parent.UpdatedAt = time.Now() + return p.ingredientRepo.Update(ctx, parent) +} + +func (p *IngredientProcessorImpl) buildIngredientResponse(ctx context.Context, id, organizationID uuid.UUID) (*models.IngredientResponse, error) { + ingredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + + return transformer.IngredientEntityToResponse(ingredient), nil } diff --git a/internal/processor/product_processor_test.go b/internal/processor/product_processor_test.go deleted file mode 100644 index 297e6c1..0000000 --- a/internal/processor/product_processor_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "apskel-pos-be/internal/models" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -// Mock repositories for testing -type MockProductRepository struct { - mock.Mock -} - -type MockCategoryRepository struct { - mock.Mock -} - -type MockProductVariantRepository struct { - mock.Mock -} - -type MockInventoryRepository struct { - mock.Mock -} - -type MockOutletRepository struct { - mock.Mock -} - -// Test helper functions -func TestCreateProductWithInventory(t *testing.T) { - // This is a basic test structure - in a real implementation, - // you would use a proper testing framework with database mocks - - t.Run("should create product with inventory when create_inventory is true", func(t *testing.T) { - // Arrange - productRepo := &MockProductRepository{} - categoryRepo := &MockCategoryRepository{} - productVariantRepo := &MockProductVariantRepository{} - inventoryRepo := &MockInventoryRepository{} - outletRepo := &MockOutletRepository{} - - processor := NewProductProcessorImpl( - productRepo, - categoryRepo, - productVariantRepo, - inventoryRepo, - outletRepo, - ) - - req := &models.CreateProductRequest{ - OrganizationID: uuid.New(), - CategoryID: uuid.New(), - Name: "Test Product", - Price: 10.0, - Cost: 5.0, - InitialStock: &[]int{100}[0], - ReorderLevel: &[]int{20}[0], - CreateInventory: true, - } - - // Mock expectations - categoryRepo.On("GetByID", mock.Anything, req.CategoryID).Return(&models.Category{}, nil) - productRepo.On("ExistsBySKU", mock.Anything, req.OrganizationID, mock.Anything, mock.Anything).Return(false, nil) - productRepo.On("ExistsByName", mock.Anything, req.OrganizationID, req.Name, mock.Anything).Return(false, nil) - productRepo.On("Create", mock.Anything, mock.Anything).Return(nil) - productRepo.On("GetWithCategory", mock.Anything, mock.Anything).Return(&models.Product{}, nil) - - // Mock outlets - outlets := []*models.Outlet{ - {ID: uuid.New()}, - {ID: uuid.New()}, - } - outletRepo.On("GetByOrganizationID", mock.Anything, req.OrganizationID).Return(outlets, nil) - - // Mock inventory creation - inventoryRepo.On("BulkCreate", mock.Anything, mock.Anything).Return(nil) - - // Act - result, err := processor.CreateProduct(context.Background(), req) - - // Assert - assert.NoError(t, err) - assert.NotNil(t, result) - - // Verify that inventory was created - inventoryRepo.AssertCalled(t, "BulkCreate", mock.Anything, mock.Anything) - outletRepo.AssertCalled(t, "GetByOrganizationID", mock.Anything, req.OrganizationID) - }) - - t.Run("should not create inventory when create_inventory is false", func(t *testing.T) { - // Arrange - productRepo := &MockProductRepository{} - categoryRepo := &MockCategoryRepository{} - productVariantRepo := &MockProductVariantRepository{} - inventoryRepo := &MockInventoryRepository{} - outletRepo := &MockOutletRepository{} - - processor := NewProductProcessorImpl( - productRepo, - categoryRepo, - productVariantRepo, - inventoryRepo, - outletRepo, - ) - - req := &models.CreateProductRequest{ - OrganizationID: uuid.New(), - CategoryID: uuid.New(), - Name: "Test Product", - Price: 10.0, - Cost: 5.0, - CreateInventory: false, - } - - // Mock expectations - categoryRepo.On("GetByID", mock.Anything, req.CategoryID).Return(&models.Category{}, nil) - productRepo.On("ExistsBySKU", mock.Anything, req.OrganizationID, mock.Anything, mock.Anything).Return(false, nil) - productRepo.On("ExistsByName", mock.Anything, req.OrganizationID, req.Name, mock.Anything).Return(false, nil) - productRepo.On("Create", mock.Anything, mock.Anything).Return(nil) - productRepo.On("GetWithCategory", mock.Anything, mock.Anything).Return(&models.Product{}, nil) - - // Act - result, err := processor.CreateProduct(context.Background(), req) - - // Assert - assert.NoError(t, err) - assert.NotNil(t, result) - - // Verify that inventory was NOT created - inventoryRepo.AssertNotCalled(t, "BulkCreate", mock.Anything, mock.Anything) - outletRepo.AssertNotCalled(t, "GetByOrganizationID", mock.Anything, mock.Anything) - }) -} - -// Mock implementations (simplified for testing) -func (m *MockProductRepository) Create(ctx context.Context, product *models.Product) error { - args := m.Called(ctx, product) - return args.Error(0) -} - -func (m *MockProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) { - args := m.Called(ctx, id) - return args.Get(0).(*models.Product), args.Error(1) -} - -func (m *MockProductRepository) GetWithCategory(ctx context.Context, id uuid.UUID) (*models.Product, error) { - args := m.Called(ctx, id) - return args.Get(0).(*models.Product), args.Error(1) -} - -func (m *MockProductRepository) ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error) { - args := m.Called(ctx, organizationID, sku, excludeID) - return args.Bool(0), args.Error(1) -} - -func (m *MockProductRepository) ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error) { - args := m.Called(ctx, organizationID, name, excludeID) - return args.Bool(0), args.Error(1) -} - -func (m *MockCategoryRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Category, error) { - args := m.Called(ctx, id) - return args.Get(0).(*models.Category), args.Error(1) -} - -func (m *MockInventoryRepository) BulkCreate(ctx context.Context, inventoryItems []*models.Inventory) error { - args := m.Called(ctx, inventoryItems) - return args.Error(0) -} - -func (m *MockOutletRepository) GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*models.Outlet, error) { - args := m.Called(ctx, organizationID) - return args.Get(0).([]*models.Outlet), args.Error(1) -} diff --git a/internal/processor/repository_interfaces.go b/internal/processor/repository_interfaces.go index 3a9acc4..2b8d8d2 100644 --- a/internal/processor/repository_interfaces.go +++ b/internal/processor/repository_interfaces.go @@ -82,3 +82,12 @@ type UnitRepository interface { Update(ctx context.Context, unit *entities.Unit) error Delete(ctx context.Context, id, organizationID uuid.UUID) error } + +type IngredientCompositionRepository interface { + Create(ctx context.Context, composition *entities.IngredientComposition) error + GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error) + GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) + Update(ctx context.Context, composition *entities.IngredientComposition) error + Delete(ctx context.Context, id, organizationID uuid.UUID) error + DeleteByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) error +} diff --git a/internal/repository/ingredient_composition_repository.go b/internal/repository/ingredient_composition_repository.go index b1658c6..2d33da1 100644 --- a/internal/repository/ingredient_composition_repository.go +++ b/internal/repository/ingredient_composition_repository.go @@ -3,285 +3,81 @@ package repository import ( "apskel-pos-be/internal/entities" "context" - "database/sql" + "fmt" "github.com/google/uuid" + "gorm.io/gorm" ) type IngredientCompositionRepository struct { - db *sql.DB + db *gorm.DB } -func NewIngredientCompositionRepository(db *sql.DB) *IngredientCompositionRepository { +func NewIngredientCompositionRepository(db *gorm.DB) *IngredientCompositionRepository { return &IngredientCompositionRepository{db: db} } func (r *IngredientCompositionRepository) Create(ctx context.Context, composition *entities.IngredientComposition) error { - query := ` - INSERT INTO ingredient_compositions (id, organization_id, outlet_id, parent_ingredient_id, child_ingredient_id, quantity, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ` - - _, err := r.db.ExecContext(ctx, query, - composition.ID, - composition.OrganizationID, - composition.OutletID, - composition.ParentIngredientID, - composition.ChildIngredientID, - composition.Quantity, - composition.CreatedAt, - composition.UpdatedAt, - ) - - return err + return r.db.WithContext(ctx).Create(composition).Error } func (r *IngredientCompositionRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error) { - query := ` - SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, - pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, - ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at - FROM ingredient_compositions ic - LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id - LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id - WHERE ic.id = $1 AND ic.organization_id = $2 - ` - - composition := &entities.IngredientComposition{} - parentIngredient := &entities.Ingredient{} - childIngredient := &entities.Ingredient{} - - err := r.db.QueryRowContext(ctx, query, id, organizationID).Scan( - &composition.ID, - &composition.OrganizationID, - &composition.OutletID, - &composition.ParentIngredientID, - &composition.ChildIngredientID, - &composition.Quantity, - &composition.CreatedAt, - &composition.UpdatedAt, - &parentIngredient.ID, - &parentIngredient.OrganizationID, - &parentIngredient.OutletID, - &parentIngredient.Name, - &parentIngredient.UnitID, - &parentIngredient.Cost, - &parentIngredient.Stock, - &parentIngredient.IsSemiFinished, - &parentIngredient.IsActive, - &parentIngredient.Metadata, - &parentIngredient.CreatedAt, - &parentIngredient.UpdatedAt, - &childIngredient.ID, - &childIngredient.OrganizationID, - &childIngredient.OutletID, - &childIngredient.Name, - &childIngredient.UnitID, - &childIngredient.Cost, - &childIngredient.Stock, - &childIngredient.IsSemiFinished, - &childIngredient.IsActive, - &childIngredient.Metadata, - &childIngredient.CreatedAt, - &childIngredient.UpdatedAt, - ) - + var composition entities.IngredientComposition + err := r.db.WithContext(ctx). + Preload("ChildIngredient.Unit"). + Preload("ParentIngredient.Unit"). + Preload("ParentIngredient.Compositions.ChildIngredient.Unit"). + Where("id = ? AND organization_id = ?", id, organizationID). + First(&composition).Error if err != nil { return nil, err } - - composition.ParentIngredient = parentIngredient - composition.ChildIngredient = childIngredient - return composition, nil + return &composition, nil } func (r *IngredientCompositionRepository) GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) { - query := ` - SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, - pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, - ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at - FROM ingredient_compositions ic - LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id - LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id - WHERE ic.parent_ingredient_id = $1 AND ic.organization_id = $2 - ORDER BY ic.created_at DESC - ` - - rows, err := r.db.QueryContext(ctx, query, parentIngredientID, organizationID) + var compositions []*entities.IngredientComposition + err := r.db.WithContext(ctx). + Preload("ChildIngredient.Unit"). + Where("parent_ingredient_id = ? AND organization_id = ?", parentIngredientID, organizationID). + Order("created_at ASC"). + Find(&compositions).Error if err != nil { return nil, err } - defer rows.Close() - - var compositions []*entities.IngredientComposition - for rows.Next() { - composition := &entities.IngredientComposition{} - parentIngredient := &entities.Ingredient{} - childIngredient := &entities.Ingredient{} - - err := rows.Scan( - &composition.ID, - &composition.OrganizationID, - &composition.OutletID, - &composition.ParentIngredientID, - &composition.ChildIngredientID, - &composition.Quantity, - &composition.CreatedAt, - &composition.UpdatedAt, - &parentIngredient.ID, - &parentIngredient.OrganizationID, - &parentIngredient.OutletID, - &parentIngredient.Name, - &parentIngredient.UnitID, - &parentIngredient.Cost, - &parentIngredient.Stock, - &parentIngredient.IsSemiFinished, - &parentIngredient.IsActive, - &parentIngredient.Metadata, - &parentIngredient.CreatedAt, - &parentIngredient.UpdatedAt, - &childIngredient.ID, - &childIngredient.OrganizationID, - &childIngredient.OutletID, - &childIngredient.Name, - &childIngredient.UnitID, - &childIngredient.Cost, - &childIngredient.Stock, - &childIngredient.IsSemiFinished, - &childIngredient.IsActive, - &childIngredient.Metadata, - &childIngredient.CreatedAt, - &childIngredient.UpdatedAt, - ) - if err != nil { - return nil, err - } - - composition.ParentIngredient = parentIngredient - composition.ChildIngredient = childIngredient - compositions = append(compositions, composition) - } - - return compositions, nil -} - -func (r *IngredientCompositionRepository) GetByChildIngredientID(ctx context.Context, childIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) { - query := ` - SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, - pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, - ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at - FROM ingredient_compositions ic - LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id - LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id - WHERE ic.child_ingredient_id = $1 AND ic.organization_id = $2 - ORDER BY ic.created_at DESC - ` - - rows, err := r.db.QueryContext(ctx, query, childIngredientID, organizationID) - if err != nil { - return nil, err - } - defer rows.Close() - - var compositions []*entities.IngredientComposition - for rows.Next() { - composition := &entities.IngredientComposition{} - parentIngredient := &entities.Ingredient{} - childIngredient := &entities.Ingredient{} - - err := rows.Scan( - &composition.ID, - &composition.OrganizationID, - &composition.OutletID, - &composition.ParentIngredientID, - &composition.ChildIngredientID, - &composition.Quantity, - &composition.CreatedAt, - &composition.UpdatedAt, - &parentIngredient.ID, - &parentIngredient.OrganizationID, - &parentIngredient.OutletID, - &parentIngredient.Name, - &parentIngredient.UnitID, - &parentIngredient.Cost, - &parentIngredient.Stock, - &parentIngredient.IsSemiFinished, - &parentIngredient.IsActive, - &parentIngredient.Metadata, - &parentIngredient.CreatedAt, - &parentIngredient.UpdatedAt, - &childIngredient.ID, - &childIngredient.OrganizationID, - &childIngredient.OutletID, - &childIngredient.Name, - &childIngredient.UnitID, - &childIngredient.Cost, - &childIngredient.Stock, - &childIngredient.IsSemiFinished, - &childIngredient.IsActive, - &childIngredient.Metadata, - &childIngredient.CreatedAt, - &childIngredient.UpdatedAt, - ) - if err != nil { - return nil, err - } - - composition.ParentIngredient = parentIngredient - composition.ChildIngredient = childIngredient - compositions = append(compositions, composition) - } - return compositions, nil } func (r *IngredientCompositionRepository) Update(ctx context.Context, composition *entities.IngredientComposition) error { - query := ` - UPDATE ingredient_compositions - SET outlet_id = $1, quantity = $2, updated_at = $3 - WHERE id = $4 AND organization_id = $5 - ` - - result, err := r.db.ExecContext(ctx, query, - composition.OutletID, - composition.Quantity, - composition.UpdatedAt, - composition.ID, - composition.OrganizationID, - ) - - if err != nil { - return err + result := r.db.WithContext(ctx). + Model(&entities.IngredientComposition{}). + Where("id = ? AND organization_id = ?", composition.ID, composition.OrganizationID). + Select("outlet_id", "quantity", "updated_at"). + Updates(composition) + if result.Error != nil { + return result.Error } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") } - - if rowsAffected == 0 { - return sql.ErrNoRows - } - return nil } func (r *IngredientCompositionRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { - query := `DELETE FROM ingredient_compositions WHERE id = $1 AND organization_id = $2` - - result, err := r.db.ExecContext(ctx, query, id, organizationID) - if err != nil { - return err + result := r.db.WithContext(ctx). + Where("id = ? AND organization_id = ?", id, organizationID). + Delete(&entities.IngredientComposition{}) + if result.Error != nil { + return result.Error } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") } - - if rowsAffected == 0 { - return sql.ErrNoRows - } - return nil } + +func (r *IngredientCompositionRepository) DeleteByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) error { + return r.db.WithContext(ctx). + Where("parent_ingredient_id = ? AND organization_id = ?", parentIngredientID, organizationID). + Delete(&entities.IngredientComposition{}).Error +} diff --git a/internal/repository/ingredient_repository.go b/internal/repository/ingredient_repository.go index 6682fa7..428d517 100644 --- a/internal/repository/ingredient_repository.go +++ b/internal/repository/ingredient_repository.go @@ -24,7 +24,11 @@ func (r *IngredientRepository) Create(ctx context.Context, ingredient *entities. func (r *IngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Ingredient, error) { var ingredient entities.Ingredient - err := r.db.WithContext(ctx).Preload("Unit").Where("id = ? AND organization_id = ?", id, organizationID).First(&ingredient).Error + err := r.db.WithContext(ctx). + Preload("Unit"). + Preload("Compositions.ChildIngredient.Unit"). + Where("id = ? AND organization_id = ?", id, organizationID). + First(&ingredient).Error if err != nil { return nil, err } @@ -57,7 +61,13 @@ func (r *IngredientRepository) GetAll(ctx context.Context, organizationID uuid.U // Get paginated results with Unit preloaded offset := (page - 1) * limit - err := query.Preload("Unit").Order("created_at DESC").Limit(limit).Offset(offset).Find(&ingredients).Error + err := query. + Preload("Unit"). + Preload("Compositions.ChildIngredient.Unit"). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&ingredients).Error if err != nil { return nil, 0, err } diff --git a/internal/router/router.go b/internal/router/router.go index 2fe6206..287ba3f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -322,6 +322,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { ingredients.GET("/:id", r.ingredientHandler.GetByID) ingredients.PUT("/:id", r.ingredientHandler.Update) ingredients.DELETE("/:id", r.ingredientHandler.Delete) + ingredients.POST("/:id/compositions", r.ingredientHandler.AddCompositions) + ingredients.PUT("/compositions/:composition_id", r.ingredientHandler.UpdateComposition) + ingredients.DELETE("/compositions/:composition_id", r.ingredientHandler.DeleteComposition) } vendors := protected.Group("/vendors") diff --git a/internal/service/ingredient_processor.go b/internal/service/ingredient_processor.go index ee75e93..95dc136 100644 --- a/internal/service/ingredient_processor.go +++ b/internal/service/ingredient_processor.go @@ -13,4 +13,7 @@ type IngredientProcessor interface { DeleteIngredient(ctx context.Context, id uuid.UUID) error GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) + UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) + DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) + AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) } diff --git a/internal/service/ingredient_service.go b/internal/service/ingredient_service.go index 1add5b9..f69a4d3 100644 --- a/internal/service/ingredient_service.go +++ b/internal/service/ingredient_service.go @@ -36,3 +36,15 @@ func (s *IngredientServiceImpl) GetIngredientByID(ctx context.Context, id uuid.U func (s *IngredientServiceImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) { return s.ingredientProcessor.ListIngredients(ctx, organizationID, outletID, page, limit, search) } + +func (s *IngredientServiceImpl) UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) { + return s.ingredientProcessor.UpdateComposition(ctx, id, req) +} + +func (s *IngredientServiceImpl) DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { + return s.ingredientProcessor.DeleteComposition(ctx, id) +} + +func (s *IngredientServiceImpl) AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) { + return s.ingredientProcessor.AddCompositions(ctx, parentID, req) +} diff --git a/internal/transformer/ingredient_transformer.go b/internal/transformer/ingredient_transformer.go new file mode 100644 index 0000000..a9cceea --- /dev/null +++ b/internal/transformer/ingredient_transformer.go @@ -0,0 +1,83 @@ +package transformer + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" +) + +// IngredientEntityToResponse converts ingredient entity to response model +func IngredientEntityToResponse(ingredient *entities.Ingredient) *models.IngredientResponse { + if ingredient == nil { + return nil + } + + m := mappers.MapIngredientEntityToModel(ingredient) + resp := &models.IngredientResponse{ + ID: m.ID, + OrganizationID: m.OrganizationID, + OutletID: m.OutletID, + Name: m.Name, + UnitID: m.UnitID, + Cost: m.Cost, + Stock: m.Stock, + IsSemiFinished: m.IsSemiFinished, + IsActive: m.IsActive, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + Unit: m.Unit, + } + + // Use preloaded compositions for semi-finished ingredients + if ingredient.IsSemiFinished && len(ingredient.Compositions) > 0 { + resp.Compositions = make([]*models.IngredientCompositionResponse, 0, len(ingredient.Compositions)) + for _, c := range ingredient.Compositions { + resp.Compositions = append(resp.Compositions, CompositionEntityToResponse(&c)) + } + } + + return resp +} + +// CompositionEntityToResponse converts composition entity to response model +func CompositionEntityToResponse(c *entities.IngredientComposition) *models.IngredientCompositionResponse { + if c == nil { + return nil + } + + resp := &models.IngredientCompositionResponse{ + ID: c.ID, + OutletID: c.OutletID, + ChildIngredientID: c.ChildIngredientID, + Quantity: c.Quantity, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + + if c.ChildIngredient != nil { + resp.ChildIngredient = mappers.MapIngredientEntityToResponse(c.ChildIngredient) + } + + if c.ParentIngredient != nil { + resp.ParentIngredient = IngredientEntityToResponse(c.ParentIngredient) + } + + return resp +} + +// IngredientsToResponses converts slice of ingredient entities to response models +func IngredientsToResponses(ingredients []*entities.Ingredient) []models.IngredientResponse { + if ingredients == nil { + return []models.IngredientResponse{} + } + + responses := make([]models.IngredientResponse, len(ingredients)) + for i, ing := range ingredients { + response := IngredientEntityToResponse(ing) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/test_composition_endpoints.md b/test_composition_endpoints.md new file mode 100644 index 0000000..0e22d1f --- /dev/null +++ b/test_composition_endpoints.md @@ -0,0 +1,123 @@ +# Test Ingredient Composition Endpoints + +## 1. Add Compositions (Bulk) + +```bash +POST http://localhost:4000/api/v1/ingredients/{ingredient_id}/compositions +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30 +Content-Type: application/json + +{ + "compositions": [ + { + "child_ingredient_id": "CHILD_INGREDIENT_UUID_1", + "quantity": 2.5 + }, + { + "child_ingredient_id": "CHILD_INGREDIENT_UUID_2", + "quantity": 1.0 + } + ] +} +``` + +**Expected Response:** +```json +{ + "success": true, + "data": { + "parent_ingredient": { + "id": "uuid", + "name": "Semi-Finished Product", + "cost": 15.50, + "compositions": [...] + }, + "compositions": [ + { + "id": "COMPOSITION_ID_1", + "child_ingredient_id": "...", + "quantity": 2.5, + "child_ingredient": {...}, + "parent_ingredient": {...} + } + ] + } +} +``` + +## 2. Update Composition + +```bash +PUT http://localhost:4000/api/v1/ingredients/compositions/{composition_id} +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30 +Content-Type: application/json + +{ + "quantity": 3.5 +} +``` + +**Expected Response:** +```json +{ + "success": true, + "data": { + "id": "composition_id", + "child_ingredient_id": "...", + "quantity": 3.5, + "child_ingredient": {...}, + "parent_ingredient": { + "id": "...", + "cost": 18.75, + "compositions": [...] + } + } +} +``` + +## 3. Delete Composition + +```bash +DELETE http://localhost:4000/api/v1/ingredients/compositions/{composition_id} +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30 +``` + +**Expected Response:** +```json +{ + "success": true, + "data": { + "id": "parent_ingredient_id", + "name": "Semi-Finished Product", + "cost": 12.25, + "compositions": [ + // remaining compositions after deletion + ] + } +} +``` + +## Steps to Test: + +1. **Get a semi-finished ingredient ID** (or create one first) +2. **Get child ingredient IDs** (raw ingredients to add as compositions) +3. **Add compositions** using the bulk endpoint +4. **Copy composition ID** from the response +5. **Update or Delete** using that composition ID + +## cURL Commands: + +### Delete Composition: +```bash +curl --request DELETE \ + --url 'http://localhost:4000/api/v1/ingredients/compositions/YOUR_COMPOSITION_ID_HERE' \ + --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30' +``` + +## Notes: + +- Replace `{ingredient_id}` with actual parent ingredient UUID +- Replace `{composition_id}` with actual composition UUID +- Replace `CHILD_INGREDIENT_UUID_1` and `CHILD_INGREDIENT_UUID_2` with actual child ingredient UUIDs +- Parent ingredient must have `is_semi_finished: true` +- All responses now include the updated parent ingredient with recalculated cost