hpp #1

Merged
aefril merged 5 commits from hpp into main 2026-04-26 16:08:02 +00:00
10 changed files with 959 additions and 0 deletions
Showing only changes of commit 9b606b4c8b - Show all commits

View File

@ -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,
}
}

View File

@ -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"`
}

60
internal/entities/hpp.go Normal file
View File

@ -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"`
}

View File

@ -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")
}

94
internal/models/hpp.go Normal file
View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())
{

View File

@ -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
}

View File

@ -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,
}
}