From 9b606b4c8b2c33af1fccb30b8ecccf00633d673e Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 23 Apr 2026 11:28:51 +0700 Subject: [PATCH] Implementasi HPP std dan real --- internal/app/app.go | 8 + internal/contract/hpp_contract.go | 94 ++++++++++ internal/entities/hpp.go | 60 +++++++ internal/handler/hpp_handler.go | 73 ++++++++ internal/models/hpp.go | 94 ++++++++++ internal/processor/hpp_processor.go | 179 +++++++++++++++++++ internal/repository/hpp_repository.go | 227 ++++++++++++++++++++++++ internal/router/router.go | 10 ++ internal/service/hpp_service.go | 78 ++++++++ internal/transformer/hpp_transformer.go | 136 ++++++++++++++ 10 files changed, 959 insertions(+) create mode 100644 internal/contract/hpp_contract.go create mode 100644 internal/entities/hpp.go create mode 100644 internal/handler/hpp_handler.go create mode 100644 internal/models/hpp.go create mode 100644 internal/processor/hpp_processor.go create mode 100644 internal/repository/hpp_repository.go create mode 100644 internal/service/hpp_service.go create mode 100644 internal/transformer/hpp_transformer.go diff --git a/internal/app/app.go b/internal/app/app.go index 28e93e6..75a4750 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -104,6 +104,7 @@ func (a *App) Initialize(cfg *config.Config) error { validators.customerAuthValidator, services.customerPointsService, services.spinGameService, + services.hppService, middleware.customerAuthMiddleware, ) @@ -190,6 +191,7 @@ type repositories struct { customerAuthRepo repository.CustomerAuthRepository customerPointsRepo repository.CustomerPointsRepository otpRepo repository.OtpRepository + hppRepo *repository.HPPRepositoryImpl txManager *repository.TxManager } @@ -235,6 +237,7 @@ func (a *App) initRepositories() *repositories { customerAuthRepo: repository.NewCustomerAuthRepository(a.db), customerPointsRepo: repository.NewCustomerPointsRepository(a.db), otpRepo: repository.NewOtpRepository(a.db), + hppRepo: repository.NewHPPRepositoryImpl(a.db), txManager: repository.NewTxManager(a.db), } } @@ -276,6 +279,7 @@ type processors struct { customerAuthProcessor processor.CustomerAuthProcessor customerPointsProcessor *processor.CustomerPointsProcessor otpProcessor processor.OtpProcessor + hppProcessor *processor.HPPProcessorImpl fileClient processor.FileClient inventoryMovementService service.InventoryMovementService } @@ -323,6 +327,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), otpProcessor: otpProcessor, + hppProcessor: processor.NewHPPProcessorImpl(repos.hppRepo), fileClient: fileClient, inventoryMovementService: inventoryMovementService, } @@ -361,6 +366,7 @@ type services struct { customerAuthService service.CustomerAuthService customerPointsService service.CustomerPointsService spinGameService service.SpinGameService + hppService *service.HPPServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -396,6 +402,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) + hppService := service.NewHPPServiceImpl(processors.hppProcessor) // Update order service with order ingredient transaction service orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) @@ -433,6 +440,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerAuthService: customerAuthService, customerPointsService: customerPointsService, spinGameService: spinGameService, + hppService: hppService, } } diff --git a/internal/contract/hpp_contract.go b/internal/contract/hpp_contract.go new file mode 100644 index 0000000..76b1b62 --- /dev/null +++ b/internal/contract/hpp_contract.go @@ -0,0 +1,94 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type StandardHPPRequest struct { + OrganizationID uuid.UUID `form:"-"` + ProductID *uuid.UUID `form:"product_id,omitempty"` + CategoryID *uuid.UUID `form:"category_id,omitempty"` +} + +type StandardHPPResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + Summary HPPSummary `json:"summary"` + Products []StandardHPPProductData `json:"products"` + Ingredients []StandardHPPIngredientData `json:"ingredients,omitempty"` +} + +type StandardHPPProductData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + ProductCost float64 `json:"product_cost"` + StandardCost float64 `json:"standard_cost"` + StandardHPPPercentage float64 `json:"standard_hpp_percentage"` + HasRecipe bool `json:"has_recipe"` +} + +type StandardHPPIngredientData struct { + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + UnitName string `json:"unit_name"` + CostPerUnit float64 `json:"cost_per_unit"` + WastePercentage float64 `json:"waste_percentage"` + TotalCost float64 `json:"total_cost"` +} + +type RealHPPRequest struct { + OrganizationID uuid.UUID `form:"-"` + OutletID *uuid.UUID `form:"outlet_id,omitempty"` + ProductID *uuid.UUID `form:"product_id,omitempty"` + CategoryID *uuid.UUID `form:"category_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` +} + +type RealHPPResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary HPPSummary `json:"summary"` + Products []RealHPPProductData `json:"products"` + TimeSeries []RealHPPTimeSeriesData `json:"time_series,omitempty"` +} + +type RealHPPProductData struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalQuantitySold int64 `json:"total_quantity_sold"` + TotalOrders int64 `json:"total_orders"` +} + +type RealHPPTimeSeriesData struct { + Date time.Time `json:"date"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalOrders int64 `json:"total_orders"` +} + +type HPPSummary struct { + AverageStandardHPP float64 `json:"average_standard_hpp"` + AverageRealHPP float64 `json:"average_real_hpp"` + HPPVariance float64 `json:"hpp_variance"` + TotalProducts int64 `json:"total_products"` +} diff --git a/internal/entities/hpp.go b/internal/entities/hpp.go new file mode 100644 index 0000000..5fc3c8c --- /dev/null +++ b/internal/entities/hpp.go @@ -0,0 +1,60 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type StandardHPPProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + ProductCost float64 `json:"product_cost"` + StandardCost float64 `json:"standard_cost"` + StandardHPPPercentage float64 `json:"standard_hpp_percentage"` + HasRecipe bool `json:"has_recipe"` +} + +type StandardHPPIngredient struct { + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + UnitName string `json:"unit_name"` + CostPerUnit float64 `json:"cost_per_unit"` + WastePercentage float64 `json:"waste_percentage"` + TotalCost float64 `json:"total_cost"` +} + +type RealHPPProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalQuantitySold int64 `json:"total_quantity_sold"` + TotalOrders int64 `json:"total_orders"` +} + +type RealHPPTimeSeries struct { + Date time.Time `json:"date"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalOrders int64 `json:"total_orders"` +} + +type HPPSummary struct { + AverageStandardHPP float64 `json:"average_standard_hpp"` + AverageRealHPP float64 `json:"average_real_hpp"` + HPPVariance float64 `json:"hpp_variance"` + TotalProducts int64 `json:"total_products"` +} diff --git a/internal/handler/hpp_handler.go b/internal/handler/hpp_handler.go new file mode 100644 index 0000000..93921a5 --- /dev/null +++ b/internal/handler/hpp_handler.go @@ -0,0 +1,73 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/transformer" + "apskel-pos-be/internal/util" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type HPPHandler struct { + hppService service.HPPService + transformer transformer.Transformer +} + +func NewHPPHandler(hppService service.HPPService, t transformer.Transformer) *HPPHandler { + return &HPPHandler{ + hppService: hppService, + transformer: t, + } +} + +func (h *HPPHandler) GetStandardHPP(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.StandardHPPRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "HPPHandler::GetStandardHPP", err.Error())}), "HPPHandler::GetStandardHPP") + return + } + + req.OrganizationID = contextInfo.OrganizationID + modelReq := transformer.StandardHPPContractToModel(&req) + + response, err := h.hppService.GetStandardHPP(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "HPPHandler::GetStandardHPP", err.Error())}), "HPPHandler::GetStandardHPP") + return + } + + contractResp := transformer.StandardHPPModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "HPPHandler::GetStandardHPP") +} + +func (h *HPPHandler) GetRealHPP(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.RealHPPRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "HPPHandler::GetRealHPP", err.Error())}), "HPPHandler::GetRealHPP") + return + } + + req.OrganizationID = contextInfo.OrganizationID + if contextInfo.OutletID != uuid.Nil { + req.OutletID = &contextInfo.OutletID + } + modelReq := transformer.RealHPPContractToModel(&req) + + response, err := h.hppService.GetRealHPP(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "HPPHandler::GetRealHPP", err.Error())}), "HPPHandler::GetRealHPP") + return + } + + contractResp := transformer.RealHPPModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "HPPHandler::GetRealHPP") +} diff --git a/internal/models/hpp.go b/internal/models/hpp.go new file mode 100644 index 0000000..2a97981 --- /dev/null +++ b/internal/models/hpp.go @@ -0,0 +1,94 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type StandardHPPRequest struct { + OrganizationID uuid.UUID `validate:"required"` + ProductID *uuid.UUID `validate:"omitempty"` + CategoryID *uuid.UUID `validate:"omitempty"` +} + +type StandardHPPResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + Summary HPPSummary `json:"summary"` + Products []StandardHPPProduct `json:"products"` + Ingredients []StandardHPPIngredient `json:"ingredients,omitempty"` +} + +type StandardHPPProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + ProductCost float64 `json:"product_cost"` + StandardCost float64 `json:"standard_cost"` + StandardHPPPercentage float64 `json:"standard_hpp_percentage"` + HasRecipe bool `json:"has_recipe"` +} + +type StandardHPPIngredient struct { + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + Quantity float64 `json:"quantity"` + UnitName string `json:"unit_name"` + CostPerUnit float64 `json:"cost_per_unit"` + WastePercentage float64 `json:"waste_percentage"` + TotalCost float64 `json:"total_cost"` +} + +type RealHPPRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + ProductID *uuid.UUID `validate:"omitempty"` + CategoryID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +type RealHPPResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary HPPSummary `json:"summary"` + Products []RealHPPProduct `json:"products"` + TimeSeries []RealHPPTimeSeries `json:"time_series,omitempty"` +} + +type RealHPPProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductSku string `json:"product_sku"` + CategoryID uuid.UUID `json:"category_id"` + CategoryName string `json:"category_name"` + SellingPrice float64 `json:"selling_price"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalQuantitySold int64 `json:"total_quantity_sold"` + TotalOrders int64 `json:"total_orders"` +} + +type RealHPPTimeSeries struct { + Date time.Time `json:"date"` + RealTotalCost float64 `json:"real_total_cost"` + RealTotalRevenue float64 `json:"real_total_revenue"` + RealHPPPercentage float64 `json:"real_hpp_percentage"` + TotalOrders int64 `json:"total_orders"` +} + +type HPPSummary struct { + AverageStandardHPP float64 `json:"average_standard_hpp"` + AverageRealHPP float64 `json:"average_real_hpp"` + HPPVariance float64 `json:"hpp_variance"` + TotalProducts int64 `json:"total_products"` +} diff --git a/internal/processor/hpp_processor.go b/internal/processor/hpp_processor.go new file mode 100644 index 0000000..75fad27 --- /dev/null +++ b/internal/processor/hpp_processor.go @@ -0,0 +1,179 @@ +package processor + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + + "github.com/google/uuid" +) + +type HPPProcessor interface { + GetStandardHPP(ctx context.Context, req *models.StandardHPPRequest) (*models.StandardHPPResponse, error) + GetRealHPP(ctx context.Context, req *models.RealHPPRequest) (*models.RealHPPResponse, error) +} + +type HPPProcessorImpl struct { + hppRepo repository.HPPRepository +} + +func NewHPPProcessorImpl(hppRepo repository.HPPRepository) *HPPProcessorImpl { + return &HPPProcessorImpl{ + hppRepo: hppRepo, + } +} + +func (p *HPPProcessorImpl) GetStandardHPP(ctx context.Context, req *models.StandardHPPRequest) (*models.StandardHPPResponse, error) { + products, err := p.hppRepo.GetStandardHPP(ctx, req.OrganizationID, req.ProductID, req.CategoryID) + if err != nil { + return nil, fmt.Errorf("failed to get standard HPP: %w", err) + } + + productIDs := make([]uuid.UUID, 0) + for _, pp := range products { + if pp.HasRecipe { + productIDs = append(productIDs, pp.ProductID) + } + } + + ingredients, err := p.hppRepo.GetStandardHPPIngredients(ctx, req.OrganizationID, productIDs) + if err != nil { + return nil, fmt.Errorf("failed to get standard HPP ingredients: %w", err) + } + + productData := make([]models.StandardHPPProduct, 0, len(products)) + var totalHPP float64 + var hppCount int64 + + for _, pp := range products { + productData = append(productData, models.StandardHPPProduct{ + ProductID: pp.ProductID, + ProductName: pp.ProductName, + ProductSku: pp.ProductSku, + CategoryID: pp.CategoryID, + CategoryName: pp.CategoryName, + SellingPrice: pp.SellingPrice, + ProductCost: pp.ProductCost, + StandardCost: pp.StandardCost, + StandardHPPPercentage: pp.StandardHPPPercentage, + HasRecipe: pp.HasRecipe, + }) + if pp.HasRecipe { + totalHPP += pp.StandardHPPPercentage + hppCount++ + } + } + + ingredientData := make([]models.StandardHPPIngredient, 0, len(ingredients)) + for _, ing := range ingredients { + ingredientData = append(ingredientData, models.StandardHPPIngredient{ + ProductID: ing.ProductID, + IngredientID: ing.IngredientID, + IngredientName: ing.IngredientName, + Quantity: ing.Quantity, + UnitName: ing.UnitName, + CostPerUnit: ing.CostPerUnit, + WastePercentage: ing.WastePercentage, + TotalCost: ing.TotalCost, + }) + } + + var avgHPP float64 + if hppCount > 0 { + avgHPP = totalHPP / float64(hppCount) + } + + return &models.StandardHPPResponse{ + OrganizationID: req.OrganizationID, + Summary: models.HPPSummary{ + AverageStandardHPP: avgHPP, + AverageRealHPP: 0, + HPPVariance: 0, + TotalProducts: int64(len(products)), + }, + Products: productData, + Ingredients: ingredientData, + }, nil +} + +func (p *HPPProcessorImpl) GetRealHPP(ctx context.Context, req *models.RealHPPRequest) (*models.RealHPPResponse, error) { + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + if req.GroupBy == "" { + req.GroupBy = "day" + } + + products, err := p.hppRepo.GetRealHPP(ctx, req.OrganizationID, req.OutletID, req.ProductID, req.CategoryID, req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("failed to get real HPP: %w", err) + } + + var timeSeries []*entities.RealHPPTimeSeries + if req.GroupBy != "" { + timeSeries, err = p.hppRepo.GetRealHPPTimeSeries(ctx, req.OrganizationID, req.OutletID, req.ProductID, req.CategoryID, req.DateFrom, req.DateTo, req.GroupBy) + if err != nil { + return nil, fmt.Errorf("failed to get real HPP time series: %w", err) + } + } + + productData := make([]models.RealHPPProduct, 0, len(products)) + var totalHPP float64 + var totalRevenue float64 + var hppCount int64 + + for _, pp := range products { + productData = append(productData, models.RealHPPProduct{ + ProductID: pp.ProductID, + ProductName: pp.ProductName, + ProductSku: pp.ProductSku, + CategoryID: pp.CategoryID, + CategoryName: pp.CategoryName, + SellingPrice: pp.SellingPrice, + RealTotalCost: pp.RealTotalCost, + RealTotalRevenue: pp.RealTotalRevenue, + RealHPPPercentage: pp.RealHPPPercentage, + TotalQuantitySold: pp.TotalQuantitySold, + TotalOrders: pp.TotalOrders, + }) + if pp.RealTotalRevenue > 0 { + totalHPP += pp.RealHPPPercentage + totalRevenue += pp.RealTotalRevenue + hppCount++ + } + } + + var avgHPP float64 + if hppCount > 0 { + avgHPP = totalHPP / float64(hppCount) + } + + tsData := make([]models.RealHPPTimeSeries, 0, len(timeSeries)) + for _, ts := range timeSeries { + tsData = append(tsData, models.RealHPPTimeSeries{ + Date: ts.Date, + RealTotalCost: ts.RealTotalCost, + RealTotalRevenue: ts.RealTotalRevenue, + RealHPPPercentage: ts.RealHPPPercentage, + TotalOrders: ts.TotalOrders, + }) + } + + return &models.RealHPPResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + GroupBy: req.GroupBy, + Summary: models.HPPSummary{ + AverageRealHPP: avgHPP, + TotalProducts: int64(len(products)), + }, + Products: productData, + TimeSeries: tsData, + }, nil +} diff --git a/internal/repository/hpp_repository.go b/internal/repository/hpp_repository.go new file mode 100644 index 0000000..9ed5b29 --- /dev/null +++ b/internal/repository/hpp_repository.go @@ -0,0 +1,227 @@ +package repository + +import ( + "context" + "time" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type HPPRepository interface { + GetStandardHPP(ctx context.Context, organizationID uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID) ([]*entities.StandardHPPProduct, error) + GetStandardHPPIngredients(ctx context.Context, organizationID uuid.UUID, productIDs []uuid.UUID) ([]*entities.StandardHPPIngredient, error) + GetRealHPP(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.RealHPPProduct, error) + GetRealHPPTimeSeries(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.RealHPPTimeSeries, error) +} + +type HPPRepositoryImpl struct { + db *gorm.DB +} + +func NewHPPRepositoryImpl(db *gorm.DB) *HPPRepositoryImpl { + return &HPPRepositoryImpl{db: db} +} + +func (r *HPPRepositoryImpl) GetStandardHPP(ctx context.Context, organizationID uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID) ([]*entities.StandardHPPProduct, error) { + var results []*entities.StandardHPPProduct + + query := r.db.WithContext(ctx). + Table("products p"). + Select(` + p.id as product_id, + p.name as product_name, + COALESCE(p.sku, '') as product_sku, + c.id as category_id, + c.name as category_name, + p.price as selling_price, + p.cost as product_cost, + COALESCE(SUM( + pr.quantity * i.cost * (1 + COALESCE(pr.waste_percentage, 0) / 100.0) + ), 0) as standard_cost, + CASE + WHEN p.price > 0 + THEN COALESCE(SUM(pr.quantity * i.cost * (1 + COALESCE(pr.waste_percentage, 0) / 100.0)), 0) / p.price * 100 + ELSE 0 + END as standard_hpp_percentage, + CASE WHEN COUNT(pr.id) > 0 THEN true ELSE false END as has_recipe + `). + Joins("LEFT JOIN product_recipes pr ON pr.product_id = p.id AND pr.organization_id = ?", organizationID). + Joins("LEFT JOIN ingredients i ON pr.ingredient_id = i.id"). + Joins("JOIN categories c ON p.category_id = c.id"). + Where("p.organization_id = ?", organizationID). + Where("p.is_active = ?", true) + + if productID != nil { + query = query.Where("p.id = ?", *productID) + } + if categoryID != nil { + query = query.Where("p.category_id = ?", *categoryID) + } + + err := query. + Group("p.id, p.name, p.sku, c.id, c.name, p.price, p.cost"). + Order("p.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *HPPRepositoryImpl) GetStandardHPPIngredients(ctx context.Context, organizationID uuid.UUID, productIDs []uuid.UUID) ([]*entities.StandardHPPIngredient, error) { + if len(productIDs) == 0 { + return []*entities.StandardHPPIngredient{}, nil + } + + var results []*entities.StandardHPPIngredient + + err := r.db.WithContext(ctx). + Table("product_recipes pr"). + Select(` + pr.product_id, + pr.ingredient_id, + i.name as ingredient_name, + pr.quantity, + COALESCE(u.name, '') as unit_name, + i.cost as cost_per_unit, + COALESCE(pr.waste_percentage, 0) as waste_percentage, + pr.quantity * i.cost * (1 + COALESCE(pr.waste_percentage, 0) / 100.0) as total_cost + `). + Joins("JOIN ingredients i ON pr.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON i.unit_id = u.id"). + Where("pr.organization_id = ?", organizationID). + Where("pr.product_id IN ?", productIDs). + Order("pr.product_id, i.name"). + Scan(&results).Error + + return results, err +} + +func (r *HPPRepositoryImpl) GetRealHPP(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.RealHPPProduct, error) { + var results []*entities.RealHPPProduct + + query := r.db.WithContext(ctx). + Table("products p"). + Select(` + p.id as product_id, + p.name as product_name, + COALESCE(p.sku, '') as product_sku, + c.id as category_id, + c.name as category_name, + p.price as selling_price, + COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.unit_cost * (oi.quantity - COALESCE(oi.refund_quantity, 0)) + ELSE 0 END + ), 0) as real_total_cost, + COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.total_price - COALESCE(oi.refund_amount, 0) + ELSE 0 END + ), 0) as real_total_revenue, + CASE + WHEN COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.total_price - COALESCE(oi.refund_amount, 0) + ELSE 0 END + ), 0) > 0 + THEN COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.unit_cost * (oi.quantity - COALESCE(oi.refund_quantity, 0)) + ELSE 0 END + ), 0) / COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.total_price - COALESCE(oi.refund_amount, 0) + ELSE 0 END + ), 0) * 100 + ELSE 0 + END as real_hpp_percentage, + COALESCE(SUM( + CASE WHEN oi.is_fully_refunded = false + THEN oi.quantity - COALESCE(oi.refund_quantity, 0) + ELSE 0 END + ), 0) as total_quantity_sold, + COALESCE(COUNT(DISTINCT oi.order_id), 0) as total_orders + `). + Joins("JOIN categories c ON p.category_id = c.id"). + Joins("LEFT JOIN order_items oi ON oi.product_id = p.id AND oi.status != 'cancelled'"). + Joins("LEFT JOIN orders o ON oi.order_id = o.id AND o.is_void = false AND o.is_refund = false AND o.status = 'completed' AND o.payment_status = 'completed' AND o.organization_id = ? AND o.created_at >= ? AND o.created_at <= ?", organizationID, dateFrom, dateTo). + Where("p.organization_id = ?", organizationID). + Where("p.is_active = ?", true) + + if outletID != nil { + query = query.Where("o.outlet_id = ? OR o.id IS NULL", *outletID) + } + if productID != nil { + query = query.Where("p.id = ?", *productID) + } + if categoryID != nil { + query = query.Where("p.category_id = ?", *categoryID) + } + + err := query. + Group("p.id, p.name, p.sku, c.id, c.name, p.price"). + Order("p.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *HPPRepositoryImpl) GetRealHPPTimeSeries(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, productID *uuid.UUID, categoryID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.RealHPPTimeSeries, error) { + var results []*entities.RealHPPTimeSeries + + var timeFormat string + switch groupBy { + case "hour": + timeFormat = "DATE_TRUNC('hour', o.created_at)" + case "week": + timeFormat = "DATE_TRUNC('week', o.created_at)" + case "month": + timeFormat = "DATE_TRUNC('month', o.created_at)" + default: + timeFormat = "DATE_TRUNC('day', o.created_at)" + } + + query := r.db.WithContext(ctx). + Table("orders o"). + Select(` + `+timeFormat+` as date, + COALESCE(SUM( + oi.unit_cost * (oi.quantity - COALESCE(oi.refund_quantity, 0)) + ), 0) as real_total_cost, + COALESCE(SUM( + oi.total_price - COALESCE(oi.refund_amount, 0) + ), 0) as real_total_revenue, + CASE + WHEN COALESCE(SUM(oi.total_price - COALESCE(oi.refund_amount, 0)), 0) > 0 + THEN COALESCE(SUM(oi.unit_cost * (oi.quantity - COALESCE(oi.refund_quantity, 0))), 0) / COALESCE(SUM(oi.total_price - COALESCE(oi.refund_amount, 0)), 0) * 100 + ELSE 0 + END as real_hpp_percentage, + COUNT(DISTINCT o.id) as total_orders + `). + Joins("JOIN order_items oi ON oi.order_id = o.id AND oi.status != 'cancelled' AND oi.is_fully_refunded = false"). + Where("o.organization_id = ?", organizationID). + Where("o.is_void = false"). + Where("o.is_refund = false"). + Where("o.status = 'completed'"). + Where("o.payment_status = 'completed'"). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + + if outletID != nil { + query = query.Where("o.outlet_id = ?", *outletID) + } + if productID != nil { + query = query.Where("oi.product_id = ?", *productID) + } + if categoryID != nil { + query = query.Where("oi.product_id IN (SELECT id FROM products WHERE category_id = ?)", *categoryID) + } + + err := query. + Group(timeFormat). + Order(timeFormat). + Scan(&results).Error + + return results, err +} diff --git a/internal/router/router.go b/internal/router/router.go index 0f95c64..cf29b2a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -46,6 +46,7 @@ type Router struct { customerAuthHandler *handler.CustomerAuthHandler customerPointsHandler *handler.CustomerPointsHandler spinGameHandler *handler.SpinGameHandler + hppHandler *handler.HPPHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware } @@ -108,6 +109,7 @@ func NewRouter(cfg *config.Config, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, + hppService *service.HPPServiceImpl, customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router { return &Router{ @@ -144,6 +146,7 @@ func NewRouter(cfg *config.Config, customerAuthHandler: handler.NewCustomerAuthHandler(customerAuthService, customerAuthValidator), customerPointsHandler: handler.NewCustomerPointsHandler(customerPointsService), spinGameHandler: handler.NewSpinGameHandler(spinGameService), + hppHandler: handler.NewHPPHandler(hppService, transformer.NewTransformer()), authMiddleware: authMiddleware, customerAuthMiddleware: customerAuthMiddleware, productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator), @@ -360,6 +363,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) } + hpp := protected.Group("/hpp") + hpp.Use(r.authMiddleware.RequireAdminOrManager()) + { + hpp.GET("/standard", r.hppHandler.GetStandardHPP) + hpp.GET("/real", r.hppHandler.GetRealHPP) + } + tables := protected.Group("/tables") tables.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/hpp_service.go b/internal/service/hpp_service.go new file mode 100644 index 0000000..2f80d02 --- /dev/null +++ b/internal/service/hpp_service.go @@ -0,0 +1,78 @@ +package service + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + + "github.com/google/uuid" +) + +type HPPService interface { + GetStandardHPP(ctx context.Context, req *models.StandardHPPRequest) (*models.StandardHPPResponse, error) + GetRealHPP(ctx context.Context, req *models.RealHPPRequest) (*models.RealHPPResponse, error) +} + +type HPPServiceImpl struct { + hppProcessor processor.HPPProcessor +} + +func NewHPPServiceImpl(hppProcessor processor.HPPProcessor) *HPPServiceImpl { + return &HPPServiceImpl{ + hppProcessor: hppProcessor, + } +} + +func (s *HPPServiceImpl) GetStandardHPP(ctx context.Context, req *models.StandardHPPRequest) (*models.StandardHPPResponse, error) { + if err := s.validateStandardHPPRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.hppProcessor.GetStandardHPP(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get standard HPP: %w", err) + } + + return response, nil +} + +func (s *HPPServiceImpl) GetRealHPP(ctx context.Context, req *models.RealHPPRequest) (*models.RealHPPResponse, error) { + if err := s.validateRealHPPRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.hppProcessor.GetRealHPP(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get real HPP: %w", err) + } + + return response, nil +} + +func (s *HPPServiceImpl) validateStandardHPPRequest(req *models.StandardHPPRequest) error { + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + return nil +} + +func (s *HPPServiceImpl) validateRealHPPRequest(req *models.RealHPPRequest) error { + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + if req.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") + } + if req.GroupBy != "" && req.GroupBy != "hour" && req.GroupBy != "day" && req.GroupBy != "week" && req.GroupBy != "month" { + return fmt.Errorf("invalid group_by value, must be one of: hour, day, week, month") + } + return nil +} diff --git a/internal/transformer/hpp_transformer.go b/internal/transformer/hpp_transformer.go new file mode 100644 index 0000000..1f1c67c --- /dev/null +++ b/internal/transformer/hpp_transformer.go @@ -0,0 +1,136 @@ +package transformer + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/util" + "time" +) + +func StandardHPPContractToModel(req *contract.StandardHPPRequest) *models.StandardHPPRequest { + return &models.StandardHPPRequest{ + OrganizationID: req.OrganizationID, + ProductID: req.ProductID, + CategoryID: req.CategoryID, + } +} + +func StandardHPPModelToContract(resp *models.StandardHPPResponse) *contract.StandardHPPResponse { + if resp == nil { + return nil + } + + products := make([]contract.StandardHPPProductData, len(resp.Products)) + for i, p := range resp.Products { + products[i] = contract.StandardHPPProductData{ + ProductID: p.ProductID, + ProductName: p.ProductName, + ProductSku: p.ProductSku, + CategoryID: p.CategoryID, + CategoryName: p.CategoryName, + SellingPrice: p.SellingPrice, + ProductCost: p.ProductCost, + StandardCost: p.StandardCost, + StandardHPPPercentage: p.StandardHPPPercentage, + HasRecipe: p.HasRecipe, + } + } + + ingredients := make([]contract.StandardHPPIngredientData, len(resp.Ingredients)) + for i, ing := range resp.Ingredients { + ingredients[i] = contract.StandardHPPIngredientData{ + ProductID: ing.ProductID, + IngredientID: ing.IngredientID, + IngredientName: ing.IngredientName, + Quantity: ing.Quantity, + UnitName: ing.UnitName, + CostPerUnit: ing.CostPerUnit, + WastePercentage: ing.WastePercentage, + TotalCost: ing.TotalCost, + } + } + + return &contract.StandardHPPResponse{ + OrganizationID: resp.OrganizationID, + Summary: hppSummaryModelToContract(resp.Summary), + Products: products, + Ingredients: ingredients, + } +} + +func RealHPPContractToModel(req *contract.RealHPPRequest) *models.RealHPPRequest { + var dateFrom, dateTo time.Time + + if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil { + if fromTime != nil { + dateFrom = *fromTime + } + if toTime != nil { + dateTo = *toTime + } + } + + return &models.RealHPPRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + ProductID: req.ProductID, + CategoryID: req.CategoryID, + DateFrom: dateFrom, + DateTo: dateTo, + GroupBy: req.GroupBy, + } +} + +func RealHPPModelToContract(resp *models.RealHPPResponse) *contract.RealHPPResponse { + if resp == nil { + return nil + } + + products := make([]contract.RealHPPProductData, len(resp.Products)) + for i, p := range resp.Products { + products[i] = contract.RealHPPProductData{ + ProductID: p.ProductID, + ProductName: p.ProductName, + ProductSku: p.ProductSku, + CategoryID: p.CategoryID, + CategoryName: p.CategoryName, + SellingPrice: p.SellingPrice, + RealTotalCost: p.RealTotalCost, + RealTotalRevenue: p.RealTotalRevenue, + RealHPPPercentage: p.RealHPPPercentage, + TotalQuantitySold: p.TotalQuantitySold, + TotalOrders: p.TotalOrders, + } + } + + timeSeries := make([]contract.RealHPPTimeSeriesData, len(resp.TimeSeries)) + for i, ts := range resp.TimeSeries { + timeSeries[i] = contract.RealHPPTimeSeriesData{ + Date: ts.Date, + RealTotalCost: ts.RealTotalCost, + RealTotalRevenue: ts.RealTotalRevenue, + RealHPPPercentage: ts.RealHPPPercentage, + TotalOrders: ts.TotalOrders, + } + } + + return &contract.RealHPPResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: hppSummaryModelToContract(resp.Summary), + Products: products, + TimeSeries: timeSeries, + } +} + +func hppSummaryModelToContract(s models.HPPSummary) contract.HPPSummary { + return contract.HPPSummary{ + AverageStandardHPP: s.AverageStandardHPP, + AverageRealHPP: s.AverageRealHPP, + HPPVariance: s.HPPVariance, + TotalProducts: s.TotalProducts, + } +}