diff --git a/internal/app/app.go b/internal/app/app.go index 545e5b7..ba3166e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -210,7 +210,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo), productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo), productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), - inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo), + inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService), paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), diff --git a/internal/contract/inventory_contract.go b/internal/contract/inventory_contract.go index 816c0a2..0b1f642 100644 --- a/internal/contract/inventory_contract.go +++ b/internal/contract/inventory_contract.go @@ -1,8 +1,9 @@ package contract import ( - "github.com/google/uuid" "time" + + "github.com/google/uuid" ) type CreateInventoryRequest struct { @@ -24,6 +25,18 @@ type AdjustInventoryRequest struct { Reason string `json:"reason" validate:"required,min=1,max=255"` } +type RestockInventoryRequest struct { + OutletID uuid.UUID `json:"outlet_id" validate:"required"` + Items []RestockItem `json:"items" validate:"required,min=1,dive"` + Reason string `json:"reason" validate:"required,min=1,max=255"` +} + +type RestockItem struct { + ItemID uuid.UUID `json:"item_id" validate:"required"` + ItemType string `json:"item_type" validate:"required,oneof=PRODUCT INGREDIENT"` + Quantity int `json:"quantity" validate:"required,min=1"` +} + type ListInventoryRequest struct { OutletID *uuid.UUID `json:"outlet_id,omitempty"` ProductID *uuid.UUID `json:"product_id,omitempty"` @@ -68,25 +81,43 @@ type InventoryAdjustmentResponse struct { AdjustedAt time.Time `json:"adjusted_at"` } +type RestockInventoryResponse struct { + OutletID uuid.UUID `json:"outlet_id"` + Items []RestockItemResult `json:"items"` + Reason string `json:"reason"` + RestockedAt time.Time `json:"restocked_at"` +} + +type RestockItemResult struct { + ItemID uuid.UUID `json:"item_id"` + ItemType string `json:"item_type"` + ItemName string `json:"item_name"` + PreviousQty int `json:"previous_quantity"` + NewQty int `json:"new_quantity"` + AddedQty int `json:"added_quantity"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + // Inventory Report Contracts type InventoryReportSummaryResponse struct { - TotalProducts int `json:"total_products"` - TotalIngredients int `json:"total_ingredients"` - TotalValue float64 `json:"total_value"` - LowStockProducts int `json:"low_stock_products"` - LowStockIngredients int `json:"low_stock_ingredients"` - ZeroStockProducts int `json:"zero_stock_products"` - ZeroStockIngredients int `json:"zero_stock_ingredients"` - TotalSoldProducts float64 `json:"total_sold_products"` + TotalProducts int `json:"total_products"` + TotalIngredients int `json:"total_ingredients"` + TotalValue float64 `json:"total_value"` + LowStockProducts int `json:"low_stock_products"` + LowStockIngredients int `json:"low_stock_ingredients"` + ZeroStockProducts int `json:"zero_stock_products"` + ZeroStockIngredients int `json:"zero_stock_ingredients"` + TotalSoldProducts float64 `json:"total_sold_products"` TotalSoldIngredients float64 `json:"total_sold_ingredients"` - OutletID string `json:"outlet_id"` - OutletName string `json:"outlet_name"` - GeneratedAt string `json:"generated_at"` + OutletID string `json:"outlet_id"` + OutletName string `json:"outlet_name"` + GeneratedAt string `json:"generated_at"` } type InventoryReportDetailResponse struct { - Summary *InventoryReportSummaryResponse `json:"summary"` - Products []*InventoryProductDetailResponse `json:"products"` + Summary *InventoryReportSummaryResponse `json:"summary"` + Products []*InventoryProductDetailResponse `json:"products"` Ingredients []*InventoryIngredientDetailResponse `json:"ingredients"` } @@ -95,29 +126,29 @@ type InventoryProductDetailResponse struct { ProductID string `json:"product_id"` ProductName string `json:"product_name"` CategoryName string `json:"category_name"` - Quantity int `json:"quantity"` + Quantity int `json:"quantity"` ReorderLevel int `json:"reorder_level"` - UnitCost float64 `json:"unit_cost"` - TotalValue float64 `json:"total_value"` - TotalIn float64 `json:"total_in"` - TotalOut float64 `json:"total_out"` - IsLowStock bool `json:"is_low_stock"` - IsZeroStock bool `json:"is_zero_stock"` - UpdatedAt string `json:"updated_at"` + UnitCost float64 `json:"unit_cost"` + TotalValue float64 `json:"total_value"` + TotalIn float64 `json:"total_in"` + TotalOut float64 `json:"total_out"` + IsLowStock bool `json:"is_low_stock"` + IsZeroStock bool `json:"is_zero_stock"` + UpdatedAt string `json:"updated_at"` } type InventoryIngredientDetailResponse struct { - ID string `json:"id"` - IngredientID string `json:"ingredient_id"` - IngredientName string `json:"ingredient_name"` - UnitName string `json:"unit_name"` - Quantity int `json:"quantity"` - ReorderLevel int `json:"reorder_level"` - UnitCost float64 `json:"unit_cost"` - TotalValue float64 `json:"total_value"` - TotalIn float64 `json:"total_in"` - TotalOut float64 `json:"total_out"` - IsLowStock bool `json:"is_low_stock"` - IsZeroStock bool `json:"is_zero_stock"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + IngredientID string `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + UnitName string `json:"unit_name"` + Quantity int `json:"quantity"` + ReorderLevel int `json:"reorder_level"` + UnitCost float64 `json:"unit_cost"` + TotalValue float64 `json:"total_value"` + TotalIn float64 `json:"total_in"` + TotalOut float64 `json:"total_out"` + IsLowStock bool `json:"is_low_stock"` + IsZeroStock bool `json:"is_zero_stock"` + UpdatedAt string `json:"updated_at"` } diff --git a/internal/handler/inventory_handler.go b/internal/handler/inventory_handler.go index e48cd3b..9b9b834 100644 --- a/internal/handler/inventory_handler.go +++ b/internal/handler/inventory_handler.go @@ -238,6 +238,34 @@ func (h *InventoryHandler) AdjustInventory(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, inventoryResponse, "InventoryHandler::AdjustInventory") } +func (h *InventoryHandler) RestockInventory(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.RestockInventoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("InventoryHandler::RestockInventory -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "InventoryHandler::RestockInventory") + return + } + + // TODO: Add validation for restock request + // validationError, validationErrorCode := h.inventoryValidator.ValidateRestockInventoryRequest(&req) + // if validationError != nil { + // validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + // util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "InventoryHandler::RestockInventory") + // return + // } + + inventoryResponse := h.inventoryService.RestockInventory(ctx, &req) + if inventoryResponse.HasErrors() { + errorResp := inventoryResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("InventoryHandler::RestockInventory -> Failed to restock inventory from service") + } + + util.HandleResponse(c.Writer, c.Request, inventoryResponse, "InventoryHandler::RestockInventory") +} + func (h *InventoryHandler) GetLowStockItems(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/models/inventory.go b/internal/models/inventory.go index 500abfc..1dfcde6 100644 --- a/internal/models/inventory.go +++ b/internal/models/inventory.go @@ -63,23 +63,23 @@ func (i *Inventory) AdjustQuantity(delta int) int { // Inventory Report Models type InventoryReportSummary struct { - TotalProducts int `json:"total_products"` - TotalIngredients int `json:"total_ingredients"` - TotalValue float64 `json:"total_value"` - LowStockProducts int `json:"low_stock_products"` - LowStockIngredients int `json:"low_stock_ingredients"` - ZeroStockProducts int `json:"zero_stock_products"` - ZeroStockIngredients int `json:"zero_stock_ingredients"` - TotalSoldProducts float64 `json:"total_sold_products"` - TotalSoldIngredients float64 `json:"total_sold_ingredients"` - OutletID uuid.UUID `json:"outlet_id"` - OutletName string `json:"outlet_name"` - GeneratedAt time.Time `json:"generated_at"` + TotalProducts int `json:"total_products"` + TotalIngredients int `json:"total_ingredients"` + TotalValue float64 `json:"total_value"` + LowStockProducts int `json:"low_stock_products"` + LowStockIngredients int `json:"low_stock_ingredients"` + ZeroStockProducts int `json:"zero_stock_products"` + ZeroStockIngredients int `json:"zero_stock_ingredients"` + TotalSoldProducts float64 `json:"total_sold_products"` + TotalSoldIngredients float64 `json:"total_sold_ingredients"` + OutletID uuid.UUID `json:"outlet_id"` + OutletName string `json:"outlet_name"` + GeneratedAt time.Time `json:"generated_at"` } type InventoryReportDetail struct { - Summary *InventoryReportSummary `json:"summary"` - Products []*InventoryProductDetail `json:"products"` + Summary *InventoryReportSummary `json:"summary"` + Products []*InventoryProductDetail `json:"products"` Ingredients []*InventoryIngredientDetail `json:"ingredients"` } @@ -88,31 +88,31 @@ type InventoryProductDetail struct { ProductID uuid.UUID `json:"product_id"` ProductName string `json:"product_name"` CategoryName string `json:"category_name"` - Quantity int `json:"quantity"` + Quantity int `json:"quantity"` ReorderLevel int `json:"reorder_level"` - UnitCost float64 `json:"unit_cost"` - TotalValue float64 `json:"total_value"` - TotalIn float64 `json:"total_in"` - TotalOut float64 `json:"total_out"` - IsLowStock bool `json:"is_low_stock"` - IsZeroStock bool `json:"is_zero_stock"` - UpdatedAt time.Time `json:"updated_at"` + UnitCost float64 `json:"unit_cost"` + TotalValue float64 `json:"total_value"` + TotalIn float64 `json:"total_in"` + TotalOut float64 `json:"total_out"` + IsLowStock bool `json:"is_low_stock"` + IsZeroStock bool `json:"is_zero_stock"` + UpdatedAt time.Time `json:"updated_at"` } type InventoryIngredientDetail struct { - ID uuid.UUID `json:"id"` - IngredientID uuid.UUID `json:"ingredient_id"` - IngredientName string `json:"ingredient_name"` - UnitName string `json:"unit_name"` - Quantity int `json:"quantity"` - ReorderLevel int `json:"reorder_level"` - UnitCost float64 `json:"unit_cost"` - TotalValue float64 `json:"total_value"` - TotalIn float64 `json:"total_in"` - TotalOut float64 `json:"total_out"` - IsLowStock bool `json:"is_low_stock"` - IsZeroStock bool `json:"is_zero_stock"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + UnitName string `json:"unit_name"` + Quantity int `json:"quantity"` + ReorderLevel int `json:"reorder_level"` + UnitCost float64 `json:"unit_cost"` + TotalValue float64 `json:"total_value"` + TotalIn float64 `json:"total_in"` + TotalOut float64 `json:"total_out"` + IsLowStock bool `json:"is_low_stock"` + IsZeroStock bool `json:"is_zero_stock"` + UpdatedAt time.Time `json:"updated_at"` } type InventoryReportFilter struct { diff --git a/internal/processor/inventory_processor.go b/internal/processor/inventory_processor.go index d4ee511..2c845c0 100644 --- a/internal/processor/inventory_processor.go +++ b/internal/processor/inventory_processor.go @@ -1,10 +1,13 @@ package processor import ( + "apskel-pos-be/internal/appcontext" "context" "fmt" "time" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -26,25 +29,32 @@ type InventoryProcessor interface { AdjustQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, delta int) (*models.InventoryResponse, error) SetQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, quantity int) (*models.InventoryResponse, error) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int, organizationID uuid.UUID) error + RestockInventory(ctx context.Context, outletID uuid.UUID, items []contract.RestockItem, reason string) (*contract.RestockInventoryResponse, error) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter, organizationID uuid.UUID) (*models.InventoryReportDetail, error) } type InventoryProcessorImpl struct { - inventoryRepo repository.InventoryRepository - productRepo ProductRepository - outletRepo OutletRepository + inventoryRepo repository.InventoryRepository + productRepo ProductRepository + outletRepo OutletRepository + ingredientRepo IngredientRepository + inventoryMovementRepo repository.InventoryMovementRepository } func NewInventoryProcessorImpl( inventoryRepo repository.InventoryRepository, productRepo ProductRepository, outletRepo OutletRepository, + ingredientRepo IngredientRepository, + inventoryMovementRepo repository.InventoryMovementRepository, ) *InventoryProcessorImpl { return &InventoryProcessorImpl{ - inventoryRepo: inventoryRepo, - productRepo: productRepo, - outletRepo: outletRepo, + inventoryRepo: inventoryRepo, + productRepo: productRepo, + outletRepo: outletRepo, + ingredientRepo: ingredientRepo, + inventoryMovementRepo: inventoryMovementRepo, } } @@ -401,3 +411,165 @@ func (p *InventoryProcessorImpl) GetZeroStockItems(ctx context.Context, outletID return responses, nil } + +func (p *InventoryProcessorImpl) RestockInventory(ctx context.Context, outletID uuid.UUID, items []contract.RestockItem, reason string) (*contract.RestockInventoryResponse, error) { + outlet, err := p.outletRepo.GetByID(ctx, outletID) + if err != nil { + return nil, fmt.Errorf("invalid outlet: %w", err) + } + + if len(items) == 0 { + return nil, fmt.Errorf("no items provided for restocking") + } + + restockResults := make([]contract.RestockItemResult, 0, len(items)) + restockedAt := time.Now() + + for _, item := range items { + result := contract.RestockItemResult{ + ItemID: item.ItemID, + ItemType: item.ItemType, + AddedQty: item.Quantity, + Success: false, + } + + switch item.ItemType { + case "PRODUCT": + if err := p.restockProduct(ctx, outletID, item.ItemID, item.Quantity, reason, outlet.OrganizationID); err != nil { + result.Error = err.Error() + } + case "INGREDIENT": + if err := p.restockIngredient(ctx, outletID, item.ItemID, item.Quantity, reason, outlet.OrganizationID); err != nil { + result.Error = err.Error() + } + default: + result.Error = fmt.Sprintf("unsupported item type: %s", item.ItemType) + } + + restockResults = append(restockResults, result) + } + + return &contract.RestockInventoryResponse{ + OutletID: outletID, + Items: restockResults, + Reason: reason, + RestockedAt: restockedAt, + }, nil +} + +func (p *InventoryProcessorImpl) restockProduct(ctx context.Context, outletID, productID uuid.UUID, quantity int, reason string, organizationID uuid.UUID) error { + contextInfo := appcontext.FromGinContext(ctx) + + var previousQuantity int + var newQuantity int + + inventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, productID, outletID) + if err != nil { + createReq := &models.CreateInventoryRequest{ + OutletID: outletID, + ProductID: productID, + Quantity: quantity, + ReorderLevel: 0, + } + inventoryEntity := mappers.CreateInventoryRequestToEntity(createReq) + if err := p.inventoryRepo.Create(ctx, inventoryEntity); err != nil { + return fmt.Errorf("failed to create inventory for product %s: %w", productID, err) + } + previousQuantity = 0 + newQuantity = quantity + } else { + previousQuantity = inventory.Quantity + newQuantity = inventory.Quantity + quantity + updateReq := &models.UpdateInventoryRequest{ + Quantity: &newQuantity, + } + mappers.UpdateInventoryEntityFromRequest(inventory, updateReq) + + if err := p.inventoryRepo.Update(ctx, inventory); err != nil { + return fmt.Errorf("failed to update inventory for product %s: %w", productID, err) + } + } + + referenceType := entities.InventoryMovementReferenceTypeManual + movement := &entities.InventoryMovement{ + OrganizationID: organizationID, + OutletID: outletID, + ItemID: productID, + ItemType: "PRODUCT", + MovementType: entities.InventoryMovementTypePurchase, + Quantity: float64(quantity), + PreviousQuantity: float64(previousQuantity), + NewQuantity: float64(newQuantity), + ReferenceType: &referenceType, + Reason: &reason, + UserID: contextInfo.UserID, + } + + if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil { + return fmt.Errorf("failed to create inventory movement record: %w", err) + } + + return nil +} + +func (p *InventoryProcessorImpl) restockIngredient(ctx context.Context, outletID, ingredientID uuid.UUID, quantity int, reason string, organizationID uuid.UUID) error { + contextInfo := appcontext.FromGinContext(ctx) + ingredient, err := p.getIngredientByID(ctx, ingredientID, organizationID) + if err != nil { + return fmt.Errorf("failed to get ingredient %s: %w", ingredientID, err) + } + + previousStock := ingredient.Stock + newStock := ingredient.Stock + float64(quantity) + + if err := p.updateIngredientStock(ctx, ingredientID, float64(quantity), organizationID); err != nil { + return fmt.Errorf("failed to update ingredient stock: %w", err) + } + + referenceType := entities.InventoryMovementReferenceTypeManual + movement := &entities.InventoryMovement{ + OrganizationID: organizationID, + OutletID: outletID, + ItemID: ingredientID, + ItemType: "INGREDIENT", + MovementType: entities.InventoryMovementTypePurchase, + Quantity: float64(quantity), + PreviousQuantity: previousStock, + NewQuantity: newStock, + ReferenceType: &referenceType, + Reason: &reason, + UserID: contextInfo.UserID, + } + + if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil { + return fmt.Errorf("failed to create inventory movement record: %w", err) + } + + return nil +} + +func (p *InventoryProcessorImpl) getIngredientByID(ctx context.Context, ingredientID, organizationID uuid.UUID) (*models.Ingredient, error) { + ingredient, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get ingredient %s: %w", ingredientID, err) + } + + return &models.Ingredient{ + ID: ingredient.ID, + Name: ingredient.Name, + Stock: ingredient.Stock, + UnitID: ingredient.UnitID, + OrganizationID: ingredient.OrganizationID, + OutletID: ingredient.OutletID, + IsActive: ingredient.IsActive, + CreatedAt: ingredient.CreatedAt, + UpdatedAt: ingredient.UpdatedAt, + }, nil +} + +func (p *InventoryProcessorImpl) updateIngredientStock(ctx context.Context, ingredientID uuid.UUID, quantityToAdd float64, organizationID uuid.UUID) error { + if err := p.ingredientRepo.UpdateStock(ctx, ingredientID, quantityToAdd, organizationID); err != nil { + return fmt.Errorf("failed to update ingredient stock: %w", err) + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index b103a17..7d125a1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -203,6 +203,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { inventory.PUT("/:id", r.inventoryHandler.UpdateInventory) inventory.DELETE("/:id", r.inventoryHandler.DeleteInventory) inventory.POST("/adjust", r.inventoryHandler.AdjustInventory) + inventory.POST("/restock", r.inventoryHandler.RestockInventory) inventory.GET("/low-stock/:outlet_id", r.inventoryHandler.GetLowStockItems) inventory.GET("/zero-stock/:outlet_id", r.inventoryHandler.GetZeroStockItems) inventory.GET("/report/summary/:outlet_id", r.inventoryHandler.GetInventoryReportSummary) diff --git a/internal/service/inventory_service.go b/internal/service/inventory_service.go index 38c1470..9dc46b2 100644 --- a/internal/service/inventory_service.go +++ b/internal/service/inventory_service.go @@ -21,6 +21,7 @@ type InventoryService interface { GetInventoryByID(ctx context.Context, id uuid.UUID) *contract.Response ListInventory(ctx context.Context, req *contract.ListInventoryRequest) *contract.Response AdjustInventory(ctx context.Context, req *contract.AdjustInventoryRequest) *contract.Response + RestockInventory(ctx context.Context, req *contract.RestockInventoryRequest) *contract.Response GetLowStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response GetZeroStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*contract.InventoryReportSummaryResponse, error) @@ -153,6 +154,16 @@ func (s *InventoryServiceImpl) AdjustInventory(ctx context.Context, req *contrac return contract.BuildSuccessResponse(contractResponse) } +func (s *InventoryServiceImpl) RestockInventory(ctx context.Context, req *contract.RestockInventoryRequest) *contract.Response { + restockResponse, err := s.inventoryProcessor.RestockInventory(ctx, req.OutletID, req.Items, req.Reason) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.InventoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(restockResponse) +} + func (s *InventoryServiceImpl) GetLowStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response { inventory, err := s.inventoryProcessor.GetLowStock(ctx, outletID, uuid.Nil) // TODO: Get organizationID from context if err != nil {