Compare commits

...
10 Commits
Author SHA1 Message Date
ryan f5d9fe5223 Implement JWT session and token 2026-05-07 15:59:58 +07:00
ryan 3721fb3cd7 List categories for self-order 2026-05-06 11:56:11 +07:00
ryan 2d6df8e4c6 Self Order - BE 2026-05-05 21:07:03 +07:00
aefril 2c76962959 Merge pull request 'ingredient composition' (#2) from feature/ingredient-composition into main
Reviewed-on: #2
2026-04-27 14:22:22 +00:00
Efril a7022dd4c1 ingredient composition 2026-04-27 21:17:12 +07:00
Efril 3542104050 fix sales 2026-04-27 13:24:16 +07:00
Efril eb95459578 fix if order amount 0 2026-04-26 23:40:01 +07:00
Efril 1a5ddd2b34 update 2026-04-26 23:25:54 +07:00
Efril 0aa280462c fix cors 2026-04-26 23:14:11 +07:00
aefril 421475006b Merge pull request 'hpp' (#1) from hpp into main
Reviewed-on: #1
2026-04-26 16:08:02 +00:00
32 changed files with 1508 additions and 828 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y ca-certificates tzdata curl && rm -rf /
RUN groupadd -r appuser && useradd -r -g appuser appuser RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=build /out/app /app COPY --from=build /out/app /app
ENV TZ=Asia/Jakarta ENV TZ=Asia/Jakarta
EXPOSE 3300 EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:3300/health || exit 1 CMD curl -fsS http://localhost:3300/health || exit 1
USER appuser USER appuser
+8
View File
@@ -79,6 +79,14 @@ func (c *Config) GetCustomerJWTExpiresTTL() int {
return c.Jwt.Customer.ExpiresTTL return c.Jwt.Customer.ExpiresTTL
} }
func (c *Config) GetSelfOrderJWTSecret() string {
return c.Jwt.SelfOrder.Secret
}
func (c *Config) GetSelfOrderJWTExpiresTTL() int {
return c.Jwt.SelfOrder.ExpiresTTL
}
func (c *Config) LogLevel() string { func (c *Config) LogLevel() string {
return c.Log.LogLevel return c.Log.LogLevel
} }
+6
View File
@@ -4,6 +4,12 @@ type Jwt struct {
Token Token `mapstructure:"token"` Token Token `mapstructure:"token"`
RefreshToken RefreshToken `mapstructure:"refresh_token"` RefreshToken RefreshToken `mapstructure:"refresh_token"`
Customer Customer `mapstructure:"customer"` Customer Customer `mapstructure:"customer"`
SelfOrder SelfOrder `mapstructure:"self_order"`
}
type SelfOrder struct {
ExpiresTTL int `mapstructure:"expires-ttl"`
Secret string `mapstructure:"secret"`
} }
type Token struct { type Token struct {
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
APP_NAME="apskel-pos" APP_NAME="apskel-pos"
PORT="3300" PORT="4000"
echo "🔄 Pulling latest code..." echo "🔄 Pulling latest code..."
git pull git pull
+3
View File
@@ -13,6 +13,9 @@ jwt:
customer: customer:
expires-ttl: 7776000 expires-ttl: 7776000
secret: "z8d5TlFCT58Q$i0%S^2M&3WtE$PMgd" secret: "z8d5TlFCT58Q$i0%S^2M&3WtE$PMgd"
self_order:
expires-ttl: 120
secret: "S3lf0rd3r_S3ss10n_S3cr3t_K3y_2024"
postgresql: postgresql:
host: 62.72.45.250 host: 62.72.45.250
+21 -5
View File
@@ -44,6 +44,16 @@ func (a *App) Initialize(cfg *config.Config) error {
validators := a.initValidators() validators := a.initValidators()
middleware := a.initMiddleware(services, cfg) middleware := a.initMiddleware(services, cfg)
healthHandler := handler.NewHealthHandler() healthHandler := handler.NewHealthHandler()
selfOrderHandler := handler.NewSelfOrderHandler(
services.orderService,
services.categoryService,
services.productService,
repos.tableRepo,
repos.outletRepo,
repos.userRepo,
cfg.GetSelfOrderJWTSecret(),
cfg.GetSelfOrderJWTExpiresTTL(),
)
a.router = router.NewRouter( a.router = router.NewRouter(
cfg, cfg,
@@ -105,6 +115,8 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerPointsService, services.customerPointsService,
services.spinGameService, services.spinGameService,
middleware.customerAuthMiddleware, middleware.customerAuthMiddleware,
selfOrderHandler,
middleware.selfOrderAuthMiddleware,
) )
return nil return nil
@@ -170,6 +182,7 @@ type repositories struct {
tableRepo *repository.TableRepository tableRepo *repository.TableRepository
unitRepo *repository.UnitRepository unitRepo *repository.UnitRepository
ingredientRepo *repository.IngredientRepository ingredientRepo *repository.IngredientRepository
ingredientCompositionRepo *repository.IngredientCompositionRepository
productRecipeRepo *repository.ProductRecipeRepository productRecipeRepo *repository.ProductRecipeRepository
vendorRepo *repository.VendorRepositoryImpl vendorRepo *repository.VendorRepositoryImpl
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
@@ -215,6 +228,7 @@ func (a *App) initRepositories() *repositories {
tableRepo: repository.NewTableRepository(a.db), tableRepo: repository.NewTableRepository(a.db),
unitRepo: repository.NewUnitRepository(a.db), unitRepo: repository.NewUnitRepository(a.db),
ingredientRepo: repository.NewIngredientRepository(a.db), ingredientRepo: repository.NewIngredientRepository(a.db),
ingredientCompositionRepo: repository.NewIngredientCompositionRepository(a.db),
productRecipeRepo: repository.NewProductRecipeRepository(a.db), productRecipeRepo: repository.NewProductRecipeRepository(a.db),
vendorRepo: repository.NewVendorRepositoryImpl(a.db), vendorRepo: repository.NewVendorRepositoryImpl(a.db),
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db), purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
@@ -302,7 +316,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
unitProcessor: processor.NewUnitProcessor(repos.unitRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
@@ -437,14 +451,16 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
} }
type middlewares struct { type middlewares struct {
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
selfOrderAuthMiddleware *middleware.SelfOrderAuthMiddleware
} }
func (a *App) initMiddleware(services *services, cfg *config.Config) *middlewares { func (a *App) initMiddleware(services *services, cfg *config.Config) *middlewares {
return &middlewares{ return &middlewares{
authMiddleware: middleware.NewAuthMiddleware(services.authService), authMiddleware: middleware.NewAuthMiddleware(services.authService),
customerAuthMiddleware: middleware.NewCustomerAuthMiddleware(cfg.GetCustomerJWTSecret()), customerAuthMiddleware: middleware.NewCustomerAuthMiddleware(cfg.GetCustomerJWTSecret()),
selfOrderAuthMiddleware: middleware.NewSelfOrderAuthMiddleware(cfg.GetSelfOrderJWTSecret()),
} }
} }
+1
View File
@@ -41,6 +41,7 @@ const (
VendorServiceEntity = "vendor_service" VendorServiceEntity = "vendor_service"
PurchaseOrderServiceEntity = "purchase_order_service" PurchaseOrderServiceEntity = "purchase_order_service"
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service" IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
IngredientCompositionServiceEntity = "ingredient_composition_service"
TableEntity = "table" TableEntity = "table"
// Gamification entities // Gamification entities
CustomerPointsEntity = "customer_points" CustomerPointsEntity = "customer_points"
@@ -1,16 +0,0 @@
package contract
import (
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type IngredientCompositionContract interface {
Create(request *models.CreateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error)
GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error)
GetByParentIngredientID(parentIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error)
GetByChildIngredientID(childIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error)
Update(id uuid.UUID, request *models.UpdateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error)
Delete(id uuid.UUID, organizationID uuid.UUID) error
}
+69
View File
@@ -0,0 +1,69 @@
package contract
import (
"github.com/google/uuid"
)
type SelfOrderSessionRequest struct {
TableID uuid.UUID `json:"table_id" validate:"required"`
CustomerName string `json:"customer_name" validate:"required"`
Phone *string `json:"phone,omitempty"`
}
type SelfOrderSessionResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
TableID uuid.UUID `json:"table_id"`
}
type SelfOrderMenuResponse struct {
OutletName string `json:"outlet_name"`
TableName string `json:"table_name"`
Categories []SelfOrderMenuCategory `json:"categories"`
}
type SelfOrderMenuCategory struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Order int `json:"order"`
Products []SelfOrderMenuItem `json:"products"`
}
type SelfOrderMenuItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Price float64 `json:"price"`
ImageURL *string `json:"image_url,omitempty"`
Variants []SelfOrderMenuVariant `json:"variants,omitempty"`
}
type SelfOrderMenuVariant struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
PriceModifier float64 `json:"price_modifier"`
}
type SelfOrderCreateOrderRequest struct {
Phone *string `json:"phone,omitempty"`
OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"`
}
type SelfOrderCreateOrderItem struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
Notes *string `json:"notes,omitempty"`
}
type SelfOrderCategoryItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Order int `json:"order"`
}
type SelfOrderListCategoriesResponse struct {
Categories []SelfOrderCategoryItem `json:"categories"`
}
+14 -13
View File
@@ -7,17 +7,18 @@ import (
) )
type Ingredient struct { type Ingredient struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
Name string `gorm:"not null;size:255" json:"name"` Name string `gorm:"not null;size:255" json:"name"`
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Unit *Unit `gorm:"foreignKey:UnitID;references:ID" json:"unit,omitempty"` Unit *Unit `gorm:"foreignKey:UnitID;references:ID" json:"unit,omitempty"`
Compositions []IngredientComposition `gorm:"foreignKey:ParentIngredientID;references:ID" json:"compositions,omitempty"`
} }
+10 -10
View File
@@ -7,14 +7,14 @@ import (
) )
type IngredientComposition struct { type IngredientComposition struct {
ID uuid.UUID `json:"id" db:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
ParentIngredientID uuid.UUID `json:"parent_ingredient_id" db:"parent_ingredient_id"` ParentIngredientID uuid.UUID `gorm:"type:uuid;not null;index" json:"parent_ingredient_id"`
ChildIngredientID uuid.UUID `json:"child_ingredient_id" db:"child_ingredient_id"` ChildIngredientID uuid.UUID `gorm:"type:uuid;not null;index" json:"child_ingredient_id"`
Quantity float64 `json:"quantity" db:"quantity"` Quantity float64 `gorm:"type:decimal(10,4);not null" json:"quantity"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` ParentIngredient *Ingredient `gorm:"foreignKey:ParentIngredientID;references:ID" json:"parent_ingredient,omitempty"`
ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` ChildIngredient *Ingredient `gorm:"foreignKey:ChildIngredientID;references:ID" json:"child_ingredient,omitempty"`
} }
+142 -62
View File
@@ -18,9 +18,7 @@ type IngredientHandler struct {
} }
func NewIngredientHandler(ingredientService IngredientService) *IngredientHandler { func NewIngredientHandler(ingredientService IngredientService) *IngredientHandler {
return &IngredientHandler{ return &IngredientHandler{ingredientService: ingredientService}
ingredientService: ingredientService,
}
} }
func (h *IngredientHandler) Create(c *gin.Context) { func (h *IngredientHandler) Create(c *gin.Context) {
@@ -29,53 +27,53 @@ func (h *IngredientHandler) Create(c *gin.Context) {
var request models.CreateIngredientRequest var request models.CreateIngredientRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
logger.FromContext(c.Request.Context()).WithError(err).Error("IngredientHandler::Create -> request binding failed") logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::Create")
return return
} }
request.OrganizationID = contextInfo.OrganizationID request.OrganizationID = contextInfo.OrganizationID
ingredientResponse, err := h.ingredientService.CreateIngredient(ctx, &request) resp, err := h.ingredientService.CreateIngredient(ctx, &request)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> Failed to create ingredient from service") logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> failed")
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::Create")
return return
} }
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Create") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::Create")
} }
func (h *IngredientHandler) GetByID(c *gin.Context) { func (h *IngredientHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
idStr := c.Param("id") id, err := uuid.Parse(c.Param("id"))
id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Invalid ingredient ID") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") }), "IngredientHandler::GetByID")
return return
} }
ingredientResponse, err := h.ingredientService.GetIngredientByID(ctx, id) resp, err := h.ingredientService.GetIngredientByID(ctx, id)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Failed to get ingredient from service") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Ingredient not found") contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Ingredient not found"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") }), "IngredientHandler::GetByID")
return return
} }
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetByID") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::GetByID")
} }
func (h *IngredientHandler) GetAll(c *gin.Context) { func (h *IngredientHandler) GetAll(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
// Get query parameters
pageStr := c.DefaultQuery("page", "1") pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "10") limitStr := c.DefaultQuery("limit", "10")
search := c.Query("search") search := c.Query("search")
@@ -83,95 +81,177 @@ func (h *IngredientHandler) GetAll(c *gin.Context) {
page, err := strconv.Atoi(pageStr) page, err := strconv.Atoi(pageStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid page parameter") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") }), "IngredientHandler::GetAll")
return return
} }
limit, err := strconv.Atoi(limitStr) limit, err := strconv.Atoi(limitStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid limit parameter") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") }), "IngredientHandler::GetAll")
return return
} }
var outletID *uuid.UUID var outletID *uuid.UUID
if outletIDStr != "" { if outletIDStr != "" {
parsedOutletID, err := uuid.Parse(outletIDStr) parsed, err := uuid.Parse(outletIDStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid outlet ID") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") }), "IngredientHandler::GetAll")
return return
} }
outletID = &parsedOutletID outletID = &parsed
} }
ingredientResponse, err := h.ingredientService.ListIngredients(ctx, contextInfo.OrganizationID, outletID, page, limit, search) resp, err := h.ingredientService.ListIngredients(ctx, contextInfo.OrganizationID, outletID, page, limit, search)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Failed to get ingredients from service") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get ingredients") contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get ingredients"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") }), "IngredientHandler::GetAll")
return return
} }
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetAll") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::GetAll")
} }
func (h *IngredientHandler) Update(c *gin.Context) { func (h *IngredientHandler) Update(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
idStr := c.Param("id") id, err := uuid.Parse(c.Param("id"))
id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Invalid ingredient ID") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") }), "IngredientHandler::Update")
return return
} }
var request models.UpdateIngredientRequest var request models.UpdateIngredientRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> request binding failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") }), "IngredientHandler::Update")
return return
} }
ingredientResponse, err := h.ingredientService.UpdateIngredient(ctx, id, &request) resp, err := h.ingredientService.UpdateIngredient(ctx, id, &request)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Failed to update ingredient from service") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") }), "IngredientHandler::Update")
return return
} }
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Update") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::Update")
} }
func (h *IngredientHandler) Delete(c *gin.Context) { func (h *IngredientHandler) Delete(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
idStr := c.Param("id") id, err := uuid.Parse(c.Param("id"))
id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Invalid ingredient ID") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID"),
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete") }), "IngredientHandler::Delete")
return return
} }
err = h.ingredientService.DeleteIngredient(ctx, id) if err := h.ingredientService.DeleteIngredient(ctx, id); err != nil {
if err != nil { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Failed to delete ingredient from service") contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) }), "IngredientHandler::Delete")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete")
return return
} }
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]interface{}{ util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
"message": "Ingredient deleted successfully", "message": "Ingredient deleted successfully",
}), "IngredientHandler::Delete") }), "IngredientHandler::Delete")
} }
// AddCompositions adds multiple composition items to a semi-finished ingredient.
func (h *IngredientHandler) AddCompositions(c *gin.Context) {
ctx := c.Request.Context()
id, err := uuid.Parse(c.Param("id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid ingredient id"),
}), "IngredientHandler::AddCompositions")
return
}
var req models.AddIngredientCompositionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::AddCompositions")
return
}
resp, err := h.ingredientService.AddCompositions(ctx, id, &req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::AddCompositions")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::AddCompositions")
}
// UpdateComposition updates quantity/outlet of a single composition entry.
func (h *IngredientHandler) UpdateComposition(c *gin.Context) {
ctx := c.Request.Context()
id, err := uuid.Parse(c.Param("composition_id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid composition id"),
}), "IngredientHandler::UpdateComposition")
return
}
var req models.UpdateIngredientCompositionRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::UpdateComposition")
return
}
resp, err := h.ingredientService.UpdateComposition(ctx, id, &req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::UpdateComposition")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::UpdateComposition")
}
// DeleteComposition removes a single composition entry.
func (h *IngredientHandler) DeleteComposition(c *gin.Context) {
ctx := c.Request.Context()
id, err := uuid.Parse(c.Param("composition_id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid composition id"),
}), "IngredientHandler::DeleteComposition")
return
}
resp, err := h.ingredientService.DeleteComposition(ctx, id)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "IngredientHandler::DeleteComposition")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "IngredientHandler::DeleteComposition")
}
+3
View File
@@ -13,4 +13,7 @@ type IngredientService interface {
DeleteIngredient(ctx context.Context, id uuid.UUID) error DeleteIngredient(ctx context.Context, id uuid.UUID) error
GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error)
ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error)
UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error)
DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error)
AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error)
} }
+449
View File
@@ -0,0 +1,449 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/transformer"
"apskel-pos-be/internal/util"
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type SelfOrderHandler struct {
orderService service.OrderService
categoryService service.CategoryService
productService service.ProductService
tableRepo repository.TableRepositoryInterface
outletRepo processor.OutletRepository
userRepo processor.UserRepository
selfOrderJWTSecret string
selfOrderJWTTTL int
}
func NewSelfOrderHandler(
orderService service.OrderService,
categoryService service.CategoryService,
productService service.ProductService,
tableRepo repository.TableRepositoryInterface,
outletRepo processor.OutletRepository,
userRepo processor.UserRepository,
selfOrderJWTSecret string,
selfOrderJWTTTL int,
) *SelfOrderHandler {
return &SelfOrderHandler{
orderService: orderService,
categoryService: categoryService,
productService: productService,
tableRepo: tableRepo,
outletRepo: outletRepo,
userRepo: userRepo,
selfOrderJWTSecret: selfOrderJWTSecret,
selfOrderJWTTTL: selfOrderJWTTTL,
}
}
func (h *SelfOrderHandler) getSelfOrderContext(c *gin.Context) (uuid.UUID, string, string, error) {
tableIDStr, _ := c.Get("self_order_table_id")
customerName, _ := c.Get("self_order_customer_name")
phoneStr, _ := c.Get("self_order_phone")
tableIDStrTyped, ok := tableIDStr.(string)
if !ok || tableIDStrTyped == "" {
return uuid.Nil, "", "", fmt.Errorf("table_id not found in context")
}
tableID, err := uuid.Parse(tableIDStrTyped)
if err != nil {
return uuid.Nil, "", "", fmt.Errorf("invalid table_id in token")
}
nameTyped, ok := customerName.(string)
if !ok || nameTyped == "" {
return uuid.Nil, "", "", fmt.Errorf("customer_name not found in context")
}
phone, _ := phoneStr.(string)
return tableID, nameTyped, phone, nil
}
func (h *SelfOrderHandler) CreateSession(c *gin.Context) {
ctx := c.Request.Context()
var req contract.SelfOrderSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateSession -> request binding failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateSession")
return
}
if req.TableID == uuid.Nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "table_id is required"),
}), "SelfOrderHandler::CreateSession")
return
}
if req.CustomerName == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "customer_name is required"),
}), "SelfOrderHandler::CreateSession")
return
}
table, err := h.tableRepo.GetByID(ctx, req.TableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateSession -> table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"),
}), "SelfOrderHandler::CreateSession")
return
}
if !table.IsActive {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not active"),
}), "SelfOrderHandler::CreateSession")
return
}
phone := ""
if req.Phone != nil {
phone = *req.Phone
}
token, expiresAt, err := util.GenerateSelfOrderSessionToken(req.TableID, req.CustomerName, phone, h.selfOrderJWTSecret, h.selfOrderJWTTTL)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateSession -> failed to generate token")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to create session"),
}), "SelfOrderHandler::CreateSession")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(&contract.SelfOrderSessionResponse{
Token: token,
ExpiresAt: expiresAt,
TableID: req.TableID,
}), "SelfOrderHandler::CreateSession")
}
func (h *SelfOrderHandler) GetMenu(c *gin.Context) {
ctx := c.Request.Context()
tableID, customerName, _, err := h.getSelfOrderContext(c)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::GetMenu")
return
}
table, err := h.tableRepo.GetByID(ctx, tableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetMenu -> table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"),
}), "SelfOrderHandler::GetMenu")
return
}
if !table.IsActive {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not active"),
}), "SelfOrderHandler::GetMenu")
return
}
outlet, err := h.outletRepo.GetByID(ctx, table.OutletID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetMenu -> outlet not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.OrderServiceEntity, "outlet not found"),
}), "SelfOrderHandler::GetMenu")
return
}
_ = customerName
isActive := true
catResp := h.categoryService.ListCategories(ctx, &contract.ListCategoriesRequest{
OrganizationID: &table.OrganizationID,
Page: 1,
Limit: 100,
})
if catResp.HasErrors() {
logger.FromContext(ctx).WithError(catResp.GetErrors()[0]).Error("SelfOrderHandler::GetMenu -> failed to list categories")
util.HandleResponse(c.Writer, c.Request, catResp, "SelfOrderHandler::GetMenu")
return
}
prodResp := h.productService.ListProducts(ctx, &contract.ListProductsRequest{
OrganizationID: &table.OrganizationID,
IsActive: &isActive,
Page: 1,
Limit: 1000,
})
if prodResp.HasErrors() {
logger.FromContext(ctx).WithError(prodResp.GetErrors()[0]).Error("SelfOrderHandler::GetMenu -> failed to list products")
util.HandleResponse(c.Writer, c.Request, prodResp, "SelfOrderHandler::GetMenu")
return
}
catList, ok := catResp.Data.(*contract.ListCategoriesResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.CategoryServiceEntity, "unexpected categories response type"),
}), "SelfOrderHandler::GetMenu")
return
}
prodList, ok := prodResp.Data.(*contract.ListProductsResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductServiceEntity, "unexpected products response type"),
}), "SelfOrderHandler::GetMenu")
return
}
menu := h.buildMenuResponse(outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
}
func (h *SelfOrderHandler) buildMenuResponse(
outlet *entities.Outlet,
table *entities.Table,
categories []contract.CategoryResponse,
products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse {
productMap := make(map[uuid.UUID][]contract.ProductResponse)
for _, p := range products {
productMap[p.CategoryID] = append(productMap[p.CategoryID], p)
}
menuCategories := make([]contract.SelfOrderMenuCategory, 0, len(categories))
for _, cat := range categories {
menuItems := make([]contract.SelfOrderMenuItem, 0)
if prods, ok := productMap[cat.ID]; ok {
for _, p := range prods {
item := contract.SelfOrderMenuItem{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Price: p.Price,
ImageURL: p.ImageURL,
}
for _, v := range p.Variants {
item.Variants = append(item.Variants, contract.SelfOrderMenuVariant{
ID: v.ID,
Name: v.Name,
PriceModifier: v.PriceModifier,
})
}
menuItems = append(menuItems, item)
}
}
menuCategories = append(menuCategories, contract.SelfOrderMenuCategory{
ID: cat.ID,
Name: cat.Name,
Description: cat.Description,
Order: cat.Order,
Products: menuItems,
})
}
return &contract.SelfOrderMenuResponse{
OutletName: outlet.Name,
TableName: table.TableName,
Categories: menuCategories,
}
}
func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ctx := c.Request.Context()
tableID, customerName, phone, err := h.getSelfOrderContext(c)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
var req contract.SelfOrderCreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> request binding failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
if err := h.validateCreateOrderRequest(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
table, err := h.tableRepo.GetByID(ctx, tableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"),
}), "SelfOrderHandler::CreateOrder")
return
}
if !table.IsActive || !table.IsAvailable() {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not available for ordering"),
}), "SelfOrderHandler::CreateOrder")
return
}
userID, err := h.resolveOrgUser(ctx, table.OrganizationID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to resolve org user")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to create self-order"),
}), "SelfOrderHandler::CreateOrder")
return
}
orderItems := make([]models.CreateOrderItemRequest, 0, len(req.OrderItems))
for _, item := range req.OrderItems {
orderItems = append(orderItems, models.CreateOrderItemRequest{
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Notes: item.Notes,
})
}
customerPhone := phone
if req.Phone != nil {
customerPhone = *req.Phone
}
metadata := make(map[string]interface{})
metadata["self_order"] = true
metadata["customer_name"] = customerName
if customerPhone != "" {
metadata["customer_phone"] = customerPhone
}
tableIDPtr := tableID
modelReq := &models.CreateOrderRequest{
OutletID: table.OutletID,
UserID: userID,
TableID: &tableIDPtr,
TableNumber: &table.TableName,
OrderType: constants.OrderTypeDineIn,
OrderItems: orderItems,
CustomerName: &customerName,
Metadata: metadata,
}
response, err := h.orderService.CreateOrder(ctx, modelReq, table.OrganizationID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to create order")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
contractResp := transformer.OrderModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder")
}
func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCreateOrderRequest) error {
if len(req.OrderItems) == 0 {
return fmt.Errorf("at least one order item is required")
}
for i, item := range req.OrderItems {
if item.ProductID == uuid.Nil {
return fmt.Errorf("product_id is required for item %d", i+1)
}
if item.Quantity <= 0 {
return fmt.Errorf("quantity must be greater than zero for item %d", i+1)
}
}
return nil
}
func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
ctx := c.Request.Context()
tableID, _, _, err := h.getSelfOrderContext(c)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::ListCategories")
return
}
table, err := h.tableRepo.GetByID(ctx, tableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ListCategories -> table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"),
}), "SelfOrderHandler::ListCategories")
return
}
catResp := h.categoryService.ListCategories(ctx, &contract.ListCategoriesRequest{
OrganizationID: &table.OrganizationID,
Page: 1,
Limit: 100,
})
if catResp.HasErrors() {
logger.FromContext(ctx).WithError(catResp.GetErrors()[0]).Error("SelfOrderHandler::ListCategories -> failed to list categories")
util.HandleResponse(c.Writer, c.Request, catResp, "SelfOrderHandler::ListCategories")
return
}
catList, ok := catResp.Data.(*contract.ListCategoriesResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.CategoryServiceEntity, "unexpected categories response type"),
}), "SelfOrderHandler::ListCategories")
return
}
items := make([]contract.SelfOrderCategoryItem, 0, len(catList.Categories))
for _, cat := range catList.Categories {
items = append(items, contract.SelfOrderCategoryItem{
ID: cat.ID,
Name: cat.Name,
Description: cat.Description,
Order: cat.Order,
})
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(&contract.SelfOrderListCategoriesResponse{
Categories: items,
}), "SelfOrderHandler::ListCategories")
}
func (h *SelfOrderHandler) resolveOrgUser(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
users, err := h.userRepo.GetByOrganizationID(ctx, organizationID)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to get users for organization: %w", err)
}
if len(users) == 0 {
return uuid.Nil, fmt.Errorf("no users found for organization")
}
return users[0].ID, nil
}
@@ -1,70 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func MapIngredientCompositionEntityToModel(entity *entities.IngredientComposition) *models.IngredientComposition {
if entity == nil {
return nil
}
return &models.IngredientComposition{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
ParentIngredientID: entity.ParentIngredientID,
ChildIngredientID: entity.ChildIngredientID,
Quantity: entity.Quantity,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ParentIngredient: MapIngredientEntityToModel(entity.ParentIngredient),
ChildIngredient: MapIngredientEntityToModel(entity.ChildIngredient),
}
}
func MapIngredientCompositionModelToEntity(model *models.IngredientComposition) *entities.IngredientComposition {
if model == nil {
return nil
}
return &entities.IngredientComposition{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
ParentIngredientID: model.ParentIngredientID,
ChildIngredientID: model.ChildIngredientID,
Quantity: model.Quantity,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
ParentIngredient: MapIngredientModelToEntity(model.ParentIngredient),
ChildIngredient: MapIngredientModelToEntity(model.ChildIngredient),
}
}
func MapIngredientCompositionEntitiesToModels(entities []*entities.IngredientComposition) []*models.IngredientComposition {
if entities == nil {
return nil
}
models := make([]*models.IngredientComposition, len(entities))
for i, entity := range entities {
models[i] = MapIngredientCompositionEntityToModel(entity)
}
return models
}
func MapIngredientCompositionModelsToEntities(models []*models.IngredientComposition) []*entities.IngredientComposition {
if models == nil {
return nil
}
entities := make([]*entities.IngredientComposition, len(models))
for i, model := range models {
entities[i] = MapIngredientCompositionModelToEntity(model)
}
return entities
}
+5 -1
View File
@@ -6,7 +6,11 @@ import (
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.Request.Header.Get("Origin")
if origin == "" {
origin = "*"
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
@@ -0,0 +1,86 @@
package middleware
import (
"strings"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/util"
"github.com/gin-gonic/gin"
)
type SelfOrderAuthMiddleware struct {
selfOrderJWTSecret string
}
func NewSelfOrderAuthMiddleware(selfOrderJWTSecret string) *SelfOrderAuthMiddleware {
return &SelfOrderAuthMiddleware{
selfOrderJWTSecret: selfOrderJWTSecret,
}
}
func (m *SelfOrderAuthMiddleware) ValidateSelfOrderToken() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Authorization header is required"),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid authorization header format"),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Token is required"),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
claims, err := util.ValidateSelfOrderToken(tokenString, m.selfOrderJWTSecret)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid token: "+err.Error()),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
tableID, ok := claims["table_id"].(string)
if !ok || tableID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "table_id not found in token"),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
customerName, ok := claims["customer_name"].(string)
if !ok || customerName == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "customer_name not found in token"),
}), "SelfOrderAuthMiddleware::ValidateSelfOrderToken")
c.Abort()
return
}
phone, _ := claims["phone"].(string)
c.Set("self_order_table_id", tableID)
c.Set("self_order_customer_name", customerName)
c.Set("self_order_phone", phone)
c.Next()
}
}
+31 -24
View File
@@ -26,15 +26,23 @@ type Ingredient struct {
} }
type CreateIngredientRequest struct { type CreateIngredientRequest struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
UnitID uuid.UUID `json:"unit_id" validate:"required"` UnitID uuid.UUID `json:"unit_id" validate:"required"`
Cost float64 `json:"cost" validate:"min=0"` Cost float64 `json:"cost" validate:"min=0"`
Stock float64 `json:"stock" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
Metadata entities.Metadata `json:"metadata"` Metadata entities.Metadata `json:"metadata"`
Compositions []CompositionItemRequest `json:"compositions,omitempty"`
}
// CompositionItemRequest is used inside create ingredient request.
type CompositionItemRequest struct {
ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
OutletID *uuid.UUID `json:"outlet_id"`
} }
type UpdateIngredientRequest struct { type UpdateIngredientRequest struct {
@@ -49,19 +57,18 @@ type UpdateIngredientRequest struct {
} }
type IngredientResponse struct { type IngredientResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"` Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"` UnitID uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
Stock float64 `json:"stock"` Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"` IsSemiFinished bool `json:"is_semi_finished"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
Metadata entities.Metadata `json:"metadata"` Metadata entities.Metadata `json:"metadata"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Unit *Unit `json:"unit,omitempty"`
// Relations Compositions []*IngredientCompositionResponse `json:"compositions,omitempty"`
Unit *Unit `json:"unit,omitempty"`
} }
+22 -33
View File
@@ -6,44 +6,33 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type IngredientComposition struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ParentIngredientID uuid.UUID `json:"parent_ingredient_id"`
ChildIngredientID uuid.UUID `json:"child_ingredient_id"`
Quantity float64 `json:"quantity"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relations
ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"`
ChildIngredient *Ingredient `json:"child_ingredient,omitempty"`
}
type CreateIngredientCompositionRequest struct {
OutletID *uuid.UUID `json:"outlet_id"`
ParentIngredientID uuid.UUID `json:"parent_ingredient_id" validate:"required"`
ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
}
type UpdateIngredientCompositionRequest struct { type UpdateIngredientCompositionRequest struct {
OutletID *uuid.UUID `json:"outlet_id"` OutletID *uuid.UUID `json:"outlet_id"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Quantity float64 `json:"quantity" validate:"required,gt=0"`
} }
type IngredientCompositionResponse struct { type IngredientCompositionResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"`
OutletID *uuid.UUID `json:"outlet_id"` ChildIngredientID uuid.UUID `json:"child_ingredient_id"`
ParentIngredientID uuid.UUID `json:"parent_ingredient_id"` Quantity float64 `json:"quantity"`
ChildIngredientID uuid.UUID `json:"child_ingredient_id"` CreatedAt time.Time `json:"created_at"`
Quantity float64 `json:"quantity"` UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"` ChildIngredient *IngredientResponse `json:"child_ingredient,omitempty"`
UpdatedAt time.Time `json:"updated_at"` ParentIngredient *IngredientResponse `json:"parent_ingredient,omitempty"`
}
// Relations type CompositionItem struct {
ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"`
ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` Quantity float64 `json:"quantity" validate:"required,gt=0"`
OutletID *uuid.UUID `json:"outlet_id"`
}
type AddIngredientCompositionsRequest struct {
Compositions []CompositionItem `json:"compositions" validate:"required,min=1,dive"`
}
type AddIngredientCompositionsResponse struct {
ParentIngredient *IngredientResponse `json:"parent_ingredient"`
Compositions []*IngredientCompositionResponse `json:"compositions"`
} }
+247 -142
View File
@@ -3,29 +3,31 @@ package processor
import ( import (
"apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/transformer"
"context" "context"
"fmt"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
type IngredientProcessorImpl struct { type IngredientProcessorImpl struct {
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
unitRepo UnitRepository unitRepo UnitRepository
compositionRepo IngredientCompositionRepository
} }
func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository) *IngredientProcessorImpl { func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository, compositionRepo IngredientCompositionRepository) *IngredientProcessorImpl {
return &IngredientProcessorImpl{ return &IngredientProcessorImpl{
ingredientRepo: ingredientRepo, ingredientRepo: ingredientRepo,
unitRepo: unitRepo, unitRepo: unitRepo,
compositionRepo: compositionRepo,
} }
} }
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
_, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID) if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -35,7 +37,7 @@ func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *mod
OutletID: req.OutletID, OutletID: req.OutletID,
Name: req.Name, Name: req.Name,
UnitID: req.UnitID, UnitID: req.UnitID,
Cost: req.Cost, Cost: req.Cost, // akan di-override oleh recalculateCost kalau semi-finished
Stock: req.Stock, Stock: req.Stock,
IsSemiFinished: req.IsSemiFinished, IsSemiFinished: req.IsSemiFinished,
IsActive: req.IsActive, IsActive: req.IsActive,
@@ -44,70 +46,30 @@ func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *mod
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
err = p.ingredientRepo.Create(ctx, ingredient) if req.IsSemiFinished {
if err != nil { ingredient.Cost = 0 // dihitung dari compositions
}
if err := p.ingredientRepo.Create(ctx, ingredient); err != nil {
return nil, err return nil, err
} }
ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, ingredient.ID, req.OrganizationID) // Save compositions if provided (only valid for semi-finished ingredients)
if err != nil { if req.IsSemiFinished && len(req.Compositions) > 0 {
return nil, err if err := p.saveCompositions(ctx, ingredient.ID, req.OrganizationID, req.Compositions); err != nil {
return nil, err
}
} }
ingredientModel := mappers.MapIngredientEntityToModel(ingredientWithUnit) return p.buildIngredientResponse(ctx, ingredient.ID, req.OrganizationID)
response := &models.IngredientResponse{
ID: ingredientModel.ID,
OrganizationID: ingredientModel.OrganizationID,
OutletID: ingredientModel.OutletID,
Name: ingredientModel.Name,
UnitID: ingredientModel.UnitID,
Cost: ingredientModel.Cost,
Stock: ingredientModel.Stock,
IsSemiFinished: ingredientModel.IsSemiFinished,
IsActive: ingredientModel.IsActive,
Metadata: ingredientModel.Metadata,
CreatedAt: ingredientModel.CreatedAt,
UpdatedAt: ingredientModel.UpdatedAt,
Unit: ingredientModel.Unit,
}
return response, nil
} }
func (p *IngredientProcessorImpl) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { func (p *IngredientProcessorImpl) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) {
contextInfo := appcontext.FromGinContext(ctx) ctxInfo := appcontext.FromGinContext(ctx)
return p.buildIngredientResponse(ctx, id, ctxInfo.OrganizationID)
// For now, we'll need to get organizationID from context or request
// This is a limitation of the current interface design
organizationID := contextInfo.OrganizationID // This should come from context
ingredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID)
if err != nil {
return nil, err
}
ingredientModel := mappers.MapIngredientEntityToModel(ingredient)
response := &models.IngredientResponse{
ID: ingredientModel.ID,
OrganizationID: ingredientModel.OrganizationID,
OutletID: ingredientModel.OutletID,
Name: ingredientModel.Name,
UnitID: ingredientModel.UnitID,
Cost: ingredientModel.Cost,
Stock: ingredientModel.Stock,
IsSemiFinished: ingredientModel.IsSemiFinished,
IsActive: ingredientModel.IsActive,
Metadata: ingredientModel.Metadata,
CreatedAt: ingredientModel.CreatedAt,
UpdatedAt: ingredientModel.UpdatedAt,
Unit: ingredientModel.Unit,
}
return response, nil
} }
func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) { func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) {
// Set default values
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -123,30 +85,9 @@ func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizat
return nil, err return nil, err
} }
// Map to response models ingredientResponses := transformer.IngredientsToResponses(ingredients)
ingredientModels := mappers.MapIngredientEntitiesToModels(ingredients)
ingredientResponses := make([]models.IngredientResponse, len(ingredientModels))
for i, ingredientModel := range ingredientModels { return &models.PaginatedResponse[models.IngredientResponse]{
ingredientResponses[i] = models.IngredientResponse{
ID: ingredientModel.ID,
OrganizationID: ingredientModel.OrganizationID,
OutletID: ingredientModel.OutletID,
Name: ingredientModel.Name,
UnitID: ingredientModel.UnitID,
Cost: ingredientModel.Cost,
Stock: ingredientModel.Stock,
IsSemiFinished: ingredientModel.IsSemiFinished,
IsActive: ingredientModel.IsActive,
Metadata: ingredientModel.Metadata,
CreatedAt: ingredientModel.CreatedAt,
UpdatedAt: ingredientModel.UpdatedAt,
Unit: ingredientModel.Unit,
}
}
// Create paginated response
paginatedResponse := &models.PaginatedResponse[models.IngredientResponse]{
Data: ingredientResponses, Data: ingredientResponses,
Pagination: models.Pagination{ Pagination: models.Pagination{
Page: page, Page: page,
@@ -154,86 +95,250 @@ func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizat
Total: int64(total), Total: int64(total),
TotalPages: (total + limit - 1) / limit, TotalPages: (total + limit - 1) / limit,
}, },
} }, nil
return paginatedResponse, nil
} }
func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) { func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) {
contextInfo := appcontext.FromGinContext(ctx) ctxInfo := appcontext.FromGinContext(ctx)
// For now, we'll need to get organizationID from context or request organizationID := ctxInfo.OrganizationID
// This is a limitation of the current interface design
organizationID := contextInfo.OrganizationID // This should come from context
// Get existing ingredient existing, err := p.ingredientRepo.GetByID(ctx, id, organizationID)
existingIngredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Validate unit exists if changed if req.UnitID != existing.UnitID {
if req.UnitID != existingIngredient.UnitID { if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil {
_, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID)
if err != nil {
return nil, err return nil, err
} }
} }
// Update fields existing.OutletID = req.OutletID
if req.OutletID != nil { existing.Name = req.Name
existingIngredient.OutletID = req.OutletID existing.UnitID = req.UnitID
existing.Stock = req.Stock
existing.IsSemiFinished = req.IsSemiFinished
existing.IsActive = req.IsActive
existing.Metadata = req.Metadata
existing.UpdatedAt = time.Now()
// Cost hanya dipakai kalau bukan semi-finished
// Kalau semi-finished, cost dihitung dari compositions
if !req.IsSemiFinished {
existing.Cost = req.Cost
} }
existingIngredient.Name = req.Name if err := p.ingredientRepo.Update(ctx, existing); err != nil {
existingIngredient.UnitID = req.UnitID
existingIngredient.Cost = req.Cost
existingIngredient.Stock = req.Stock
existingIngredient.IsSemiFinished = req.IsSemiFinished
existingIngredient.IsActive = req.IsActive
existingIngredient.Metadata = req.Metadata
existingIngredient.UpdatedAt = time.Now()
// Save to database
err = p.ingredientRepo.Update(ctx, existingIngredient)
if err != nil {
return nil, err return nil, err
} }
// Get with relations return p.buildIngredientResponse(ctx, id, organizationID)
ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, id, organizationID)
if err != nil {
return nil, err
}
// Map to response
ingredientModel := mappers.MapIngredientEntityToModel(ingredientWithUnit)
response := &models.IngredientResponse{
ID: ingredientModel.ID,
OrganizationID: ingredientModel.OrganizationID,
OutletID: ingredientModel.OutletID,
Name: ingredientModel.Name,
UnitID: ingredientModel.UnitID,
Cost: ingredientModel.Cost,
Stock: ingredientModel.Stock,
IsSemiFinished: ingredientModel.IsSemiFinished,
IsActive: ingredientModel.IsActive,
Metadata: ingredientModel.Metadata,
CreatedAt: ingredientModel.CreatedAt,
UpdatedAt: ingredientModel.UpdatedAt,
Unit: ingredientModel.Unit,
}
return response, nil
} }
func (p *IngredientProcessorImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error { func (p *IngredientProcessorImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error {
contextInfo := appcontext.FromGinContext(ctx) ctxInfo := appcontext.FromGinContext(ctx)
organizationID := contextInfo.OrganizationID return p.ingredientRepo.Delete(ctx, id, ctxInfo.OrganizationID)
}
err := p.ingredientRepo.Delete(ctx, id, organizationID) func (p *IngredientProcessorImpl) AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) {
ctxInfo := appcontext.FromGinContext(ctx)
organizationID := ctxInfo.OrganizationID
// Parent must exist and be semi-finished
parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID)
if err != nil {
return nil, fmt.Errorf("parent ingredient not found: %w", err)
}
if !parent.IsSemiFinished {
return nil, fmt.Errorf("parent ingredient must be marked as semi-finished")
}
now := time.Now()
createdIDs := make([]uuid.UUID, 0, len(req.Compositions))
// Validate and create all compositions
for _, item := range req.Compositions {
// Child must exist
if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil {
return nil, fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err)
}
if item.ChildIngredientID == parentID {
return nil, fmt.Errorf("child ingredient cannot be the same as parent")
}
// Resolve outlet
outletID := item.OutletID
if outletID == nil && ctxInfo.OutletID != uuid.Nil {
outletID = &ctxInfo.OutletID
}
compositionID := uuid.New()
composition := &entities.IngredientComposition{
ID: compositionID,
OrganizationID: organizationID,
OutletID: outletID,
ParentIngredientID: parentID,
ChildIngredientID: item.ChildIngredientID,
Quantity: item.Quantity,
CreatedAt: now,
UpdatedAt: now,
}
if err := p.compositionRepo.Create(ctx, composition); err != nil {
return nil, fmt.Errorf("failed to add composition: %w", err)
}
createdIDs = append(createdIDs, compositionID)
}
// Recalculate parent cost
if err := p.recalculateCost(ctx, parentID, organizationID); err != nil {
return nil, err
}
// Get updated parent ingredient with all compositions
updatedParent, err := p.buildIngredientResponse(ctx, parentID, organizationID)
if err != nil {
return nil, err
}
// Get created compositions with full details
createdCompositions := make([]*models.IngredientCompositionResponse, 0, len(createdIDs))
for _, id := range createdIDs {
comp, err := p.compositionRepo.GetByID(ctx, id, organizationID)
if err != nil {
return nil, err
}
createdCompositions = append(createdCompositions, transformer.CompositionEntityToResponse(comp))
}
return &models.AddIngredientCompositionsResponse{
ParentIngredient: updatedParent,
Compositions: createdCompositions,
}, nil
}
func (p *IngredientProcessorImpl) UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) {
ctxInfo := appcontext.FromGinContext(ctx)
existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID)
if err != nil {
return nil, fmt.Errorf("composition not found: %w", err)
}
existing.Quantity = req.Quantity
existing.OutletID = req.OutletID
existing.UpdatedAt = time.Now()
if err := p.compositionRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update composition: %w", err)
}
// Recalculate parent cost since quantity changed
if err := p.recalculateCost(ctx, existing.ParentIngredientID, ctxInfo.OrganizationID); err != nil {
return nil, fmt.Errorf("failed to recalculate cost: %w", err)
}
updated, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID)
if err != nil {
return nil, err
}
return transformer.CompositionEntityToResponse(updated), nil
}
func (p *IngredientProcessorImpl) DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) {
ctxInfo := appcontext.FromGinContext(ctx)
existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID)
if err != nil {
return nil, fmt.Errorf("composition not found: %w", err)
}
parentID := existing.ParentIngredientID
if err := p.compositionRepo.Delete(ctx, id, ctxInfo.OrganizationID); err != nil {
return nil, err
}
// Recalculate parent cost after removal
if err := p.recalculateCost(ctx, parentID, ctxInfo.OrganizationID); err != nil {
return nil, err
}
// Return updated parent ingredient
return p.buildIngredientResponse(ctx, parentID, ctxInfo.OrganizationID)
}
// --- private helpers ---
func (p *IngredientProcessorImpl) saveCompositions(ctx context.Context, parentID, organizationID uuid.UUID, items []models.CompositionItemRequest) error {
ctxInfo := appcontext.FromGinContext(ctx)
now := time.Now()
for _, item := range items {
if item.ChildIngredientID == parentID {
return fmt.Errorf("child ingredient cannot be the same as parent")
}
if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil {
return fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err)
}
// Resolve outlet: use item's outlet if provided, otherwise fall back to context outlet
outletID := item.OutletID
if outletID == nil && ctxInfo.OutletID != uuid.Nil {
outletID = &ctxInfo.OutletID
}
composition := &entities.IngredientComposition{
ID: uuid.New(),
OrganizationID: organizationID,
OutletID: outletID,
ParentIngredientID: parentID,
ChildIngredientID: item.ChildIngredientID,
Quantity: item.Quantity,
CreatedAt: now,
UpdatedAt: now,
}
if err := p.compositionRepo.Create(ctx, composition); err != nil {
return fmt.Errorf("failed to save composition: %w", err)
}
}
// Recalculate cost of the semi-finished parent from child costs
return p.recalculateCost(ctx, parentID, organizationID)
}
// recalculateCost sums up (child.cost * composition.quantity) and updates the parent ingredient cost.
func (p *IngredientProcessorImpl) recalculateCost(ctx context.Context, parentID, organizationID uuid.UUID) error {
compositions, err := p.compositionRepo.GetByParentIngredientID(ctx, parentID, organizationID)
if err != nil { if err != nil {
return err return err
} }
return nil var totalCost float64
for _, c := range compositions {
if c.ChildIngredient != nil {
totalCost += c.ChildIngredient.Cost * c.Quantity
}
}
parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID)
if err != nil {
return err
}
parent.Cost = totalCost
parent.UpdatedAt = time.Now()
return p.ingredientRepo.Update(ctx, parent)
}
func (p *IngredientProcessorImpl) buildIngredientResponse(ctx context.Context, id, organizationID uuid.UUID) (*models.IngredientResponse, error) {
ingredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID)
if err != nil {
return nil, err
}
return transformer.IngredientEntityToResponse(ingredient), nil
} }
@@ -1,180 +0,0 @@
package processor
import (
"context"
"testing"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock repositories for testing
type MockProductRepository struct {
mock.Mock
}
type MockCategoryRepository struct {
mock.Mock
}
type MockProductVariantRepository struct {
mock.Mock
}
type MockInventoryRepository struct {
mock.Mock
}
type MockOutletRepository struct {
mock.Mock
}
// Test helper functions
func TestCreateProductWithInventory(t *testing.T) {
// This is a basic test structure - in a real implementation,
// you would use a proper testing framework with database mocks
t.Run("should create product with inventory when create_inventory is true", func(t *testing.T) {
// Arrange
productRepo := &MockProductRepository{}
categoryRepo := &MockCategoryRepository{}
productVariantRepo := &MockProductVariantRepository{}
inventoryRepo := &MockInventoryRepository{}
outletRepo := &MockOutletRepository{}
processor := NewProductProcessorImpl(
productRepo,
categoryRepo,
productVariantRepo,
inventoryRepo,
outletRepo,
)
req := &models.CreateProductRequest{
OrganizationID: uuid.New(),
CategoryID: uuid.New(),
Name: "Test Product",
Price: 10.0,
Cost: 5.0,
InitialStock: &[]int{100}[0],
ReorderLevel: &[]int{20}[0],
CreateInventory: true,
}
// Mock expectations
categoryRepo.On("GetByID", mock.Anything, req.CategoryID).Return(&models.Category{}, nil)
productRepo.On("ExistsBySKU", mock.Anything, req.OrganizationID, mock.Anything, mock.Anything).Return(false, nil)
productRepo.On("ExistsByName", mock.Anything, req.OrganizationID, req.Name, mock.Anything).Return(false, nil)
productRepo.On("Create", mock.Anything, mock.Anything).Return(nil)
productRepo.On("GetWithCategory", mock.Anything, mock.Anything).Return(&models.Product{}, nil)
// Mock outlets
outlets := []*models.Outlet{
{ID: uuid.New()},
{ID: uuid.New()},
}
outletRepo.On("GetByOrganizationID", mock.Anything, req.OrganizationID).Return(outlets, nil)
// Mock inventory creation
inventoryRepo.On("BulkCreate", mock.Anything, mock.Anything).Return(nil)
// Act
result, err := processor.CreateProduct(context.Background(), req)
// Assert
assert.NoError(t, err)
assert.NotNil(t, result)
// Verify that inventory was created
inventoryRepo.AssertCalled(t, "BulkCreate", mock.Anything, mock.Anything)
outletRepo.AssertCalled(t, "GetByOrganizationID", mock.Anything, req.OrganizationID)
})
t.Run("should not create inventory when create_inventory is false", func(t *testing.T) {
// Arrange
productRepo := &MockProductRepository{}
categoryRepo := &MockCategoryRepository{}
productVariantRepo := &MockProductVariantRepository{}
inventoryRepo := &MockInventoryRepository{}
outletRepo := &MockOutletRepository{}
processor := NewProductProcessorImpl(
productRepo,
categoryRepo,
productVariantRepo,
inventoryRepo,
outletRepo,
)
req := &models.CreateProductRequest{
OrganizationID: uuid.New(),
CategoryID: uuid.New(),
Name: "Test Product",
Price: 10.0,
Cost: 5.0,
CreateInventory: false,
}
// Mock expectations
categoryRepo.On("GetByID", mock.Anything, req.CategoryID).Return(&models.Category{}, nil)
productRepo.On("ExistsBySKU", mock.Anything, req.OrganizationID, mock.Anything, mock.Anything).Return(false, nil)
productRepo.On("ExistsByName", mock.Anything, req.OrganizationID, req.Name, mock.Anything).Return(false, nil)
productRepo.On("Create", mock.Anything, mock.Anything).Return(nil)
productRepo.On("GetWithCategory", mock.Anything, mock.Anything).Return(&models.Product{}, nil)
// Act
result, err := processor.CreateProduct(context.Background(), req)
// Assert
assert.NoError(t, err)
assert.NotNil(t, result)
// Verify that inventory was NOT created
inventoryRepo.AssertNotCalled(t, "BulkCreate", mock.Anything, mock.Anything)
outletRepo.AssertNotCalled(t, "GetByOrganizationID", mock.Anything, mock.Anything)
})
}
// Mock implementations (simplified for testing)
func (m *MockProductRepository) Create(ctx context.Context, product *models.Product) error {
args := m.Called(ctx, product)
return args.Error(0)
}
func (m *MockProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) {
args := m.Called(ctx, id)
return args.Get(0).(*models.Product), args.Error(1)
}
func (m *MockProductRepository) GetWithCategory(ctx context.Context, id uuid.UUID) (*models.Product, error) {
args := m.Called(ctx, id)
return args.Get(0).(*models.Product), args.Error(1)
}
func (m *MockProductRepository) ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error) {
args := m.Called(ctx, organizationID, sku, excludeID)
return args.Bool(0), args.Error(1)
}
func (m *MockProductRepository) ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error) {
args := m.Called(ctx, organizationID, name, excludeID)
return args.Bool(0), args.Error(1)
}
func (m *MockCategoryRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Category, error) {
args := m.Called(ctx, id)
return args.Get(0).(*models.Category), args.Error(1)
}
func (m *MockInventoryRepository) BulkCreate(ctx context.Context, inventoryItems []*models.Inventory) error {
args := m.Called(ctx, inventoryItems)
return args.Error(0)
}
func (m *MockOutletRepository) GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*models.Outlet, error) {
args := m.Called(ctx, organizationID)
return args.Get(0).([]*models.Outlet), args.Error(1)
}
@@ -82,3 +82,12 @@ type UnitRepository interface {
Update(ctx context.Context, unit *entities.Unit) error Update(ctx context.Context, unit *entities.Unit) error
Delete(ctx context.Context, id, organizationID uuid.UUID) error Delete(ctx context.Context, id, organizationID uuid.UUID) error
} }
type IngredientCompositionRepository interface {
Create(ctx context.Context, composition *entities.IngredientComposition) error
GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error)
GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error)
Update(ctx context.Context, composition *entities.IngredientComposition) error
Delete(ctx context.Context, id, organizationID uuid.UUID) error
DeleteByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) error
}
+31 -21
View File
@@ -77,33 +77,43 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
dateFormat = "DATE(o.created_at)" dateFormat = "DATE(o.created_at)"
} }
query := r.db.WithContext(ctx). outletFilter := ""
Table("orders o"). args := []interface{}{organizationID, false, false, string(entities.PaymentStatusCompleted), dateFrom, dateTo}
Select(` if outletID != nil {
`+dateFormat+` as date, outletFilter = "AND o.outlet_id = ?"
args = append(args, *outletID)
}
rawQuery := `
SELECT
` + dateFormat + ` as date,
COALESCE(SUM(o.total_amount), 0) as sales, COALESCE(SUM(o.total_amount), 0) as sales,
COUNT(o.id) as orders, COUNT(o.id) as orders,
COALESCE(SUM(CASE WHEN oi.status != 'cancelled' AND oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as items, COALESCE(SUM(oi_agg.total_items), 0) as items,
COALESCE(SUM(o.tax_amount), 0) as tax, COALESCE(SUM(o.tax_amount), 0) as tax,
COALESCE(SUM(o.discount_amount), 0) as discount, COALESCE(SUM(o.discount_amount), 0) as discount,
COALESCE(SUM(o.total_amount - o.tax_amount - o.discount_amount), 0) as net_sales COALESCE(SUM(o.total_amount - o.tax_amount - o.discount_amount), 0) as net_sales
`). FROM orders o
Joins("LEFT JOIN order_items oi ON o.id = oi.order_id"). LEFT JOIN (
Where("o.organization_id = ?", organizationID). SELECT
Where("o.is_void = ?", false). oi.order_id,
Where("o.is_refund = ?", false). SUM(oi.quantity - COALESCE(oi.refund_quantity, 0)) as total_items
Where("o.payment_status = ?", entities.PaymentStatusCompleted). FROM order_items oi
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) WHERE oi.status != 'cancelled'
AND oi.is_fully_refunded = false
if outletID != nil { GROUP BY oi.order_id
query = query.Where("o.outlet_id = ?", *outletID) ) oi_agg ON oi_agg.order_id = o.id
} WHERE o.organization_id = ?
AND o.is_void = ?
err := query. AND o.is_refund = ?
Group("date"). AND o.payment_status = ?
Order("date ASC"). AND o.created_at >= ? AND o.created_at <= ?
Scan(&results).Error ` + outletFilter + `
GROUP BY date
ORDER BY date ASC
`
err := r.db.WithContext(ctx).Raw(rawQuery, args...).Scan(&results).Error
return results, err return results, err
} }
@@ -3,285 +3,81 @@ package repository
import ( import (
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"context" "context"
"database/sql" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type IngredientCompositionRepository struct { type IngredientCompositionRepository struct {
db *sql.DB db *gorm.DB
} }
func NewIngredientCompositionRepository(db *sql.DB) *IngredientCompositionRepository { func NewIngredientCompositionRepository(db *gorm.DB) *IngredientCompositionRepository {
return &IngredientCompositionRepository{db: db} return &IngredientCompositionRepository{db: db}
} }
func (r *IngredientCompositionRepository) Create(ctx context.Context, composition *entities.IngredientComposition) error { func (r *IngredientCompositionRepository) Create(ctx context.Context, composition *entities.IngredientComposition) error {
query := ` return r.db.WithContext(ctx).Create(composition).Error
INSERT INTO ingredient_compositions (id, organization_id, outlet_id, parent_ingredient_id, child_ingredient_id, quantity, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.ExecContext(ctx, query,
composition.ID,
composition.OrganizationID,
composition.OutletID,
composition.ParentIngredientID,
composition.ChildIngredientID,
composition.Quantity,
composition.CreatedAt,
composition.UpdatedAt,
)
return err
} }
func (r *IngredientCompositionRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error) { func (r *IngredientCompositionRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error) {
query := ` var composition entities.IngredientComposition
SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, err := r.db.WithContext(ctx).
pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, Preload("ChildIngredient.Unit").
ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at Preload("ParentIngredient.Unit").
FROM ingredient_compositions ic Preload("ParentIngredient.Compositions.ChildIngredient.Unit").
LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id Where("id = ? AND organization_id = ?", id, organizationID).
LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id First(&composition).Error
WHERE ic.id = $1 AND ic.organization_id = $2
`
composition := &entities.IngredientComposition{}
parentIngredient := &entities.Ingredient{}
childIngredient := &entities.Ingredient{}
err := r.db.QueryRowContext(ctx, query, id, organizationID).Scan(
&composition.ID,
&composition.OrganizationID,
&composition.OutletID,
&composition.ParentIngredientID,
&composition.ChildIngredientID,
&composition.Quantity,
&composition.CreatedAt,
&composition.UpdatedAt,
&parentIngredient.ID,
&parentIngredient.OrganizationID,
&parentIngredient.OutletID,
&parentIngredient.Name,
&parentIngredient.UnitID,
&parentIngredient.Cost,
&parentIngredient.Stock,
&parentIngredient.IsSemiFinished,
&parentIngredient.IsActive,
&parentIngredient.Metadata,
&parentIngredient.CreatedAt,
&parentIngredient.UpdatedAt,
&childIngredient.ID,
&childIngredient.OrganizationID,
&childIngredient.OutletID,
&childIngredient.Name,
&childIngredient.UnitID,
&childIngredient.Cost,
&childIngredient.Stock,
&childIngredient.IsSemiFinished,
&childIngredient.IsActive,
&childIngredient.Metadata,
&childIngredient.CreatedAt,
&childIngredient.UpdatedAt,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &composition, nil
composition.ParentIngredient = parentIngredient
composition.ChildIngredient = childIngredient
return composition, nil
} }
func (r *IngredientCompositionRepository) GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) { func (r *IngredientCompositionRepository) GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) {
query := ` var compositions []*entities.IngredientComposition
SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, err := r.db.WithContext(ctx).
pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, Preload("ChildIngredient.Unit").
ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at Where("parent_ingredient_id = ? AND organization_id = ?", parentIngredientID, organizationID).
FROM ingredient_compositions ic Order("created_at ASC").
LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id Find(&compositions).Error
LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id
WHERE ic.parent_ingredient_id = $1 AND ic.organization_id = $2
ORDER BY ic.created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, parentIngredientID, organizationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var compositions []*entities.IngredientComposition
for rows.Next() {
composition := &entities.IngredientComposition{}
parentIngredient := &entities.Ingredient{}
childIngredient := &entities.Ingredient{}
err := rows.Scan(
&composition.ID,
&composition.OrganizationID,
&composition.OutletID,
&composition.ParentIngredientID,
&composition.ChildIngredientID,
&composition.Quantity,
&composition.CreatedAt,
&composition.UpdatedAt,
&parentIngredient.ID,
&parentIngredient.OrganizationID,
&parentIngredient.OutletID,
&parentIngredient.Name,
&parentIngredient.UnitID,
&parentIngredient.Cost,
&parentIngredient.Stock,
&parentIngredient.IsSemiFinished,
&parentIngredient.IsActive,
&parentIngredient.Metadata,
&parentIngredient.CreatedAt,
&parentIngredient.UpdatedAt,
&childIngredient.ID,
&childIngredient.OrganizationID,
&childIngredient.OutletID,
&childIngredient.Name,
&childIngredient.UnitID,
&childIngredient.Cost,
&childIngredient.Stock,
&childIngredient.IsSemiFinished,
&childIngredient.IsActive,
&childIngredient.Metadata,
&childIngredient.CreatedAt,
&childIngredient.UpdatedAt,
)
if err != nil {
return nil, err
}
composition.ParentIngredient = parentIngredient
composition.ChildIngredient = childIngredient
compositions = append(compositions, composition)
}
return compositions, nil
}
func (r *IngredientCompositionRepository) GetByChildIngredientID(ctx context.Context, childIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) {
query := `
SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at,
pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at,
ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at
FROM ingredient_compositions ic
LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id
LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id
WHERE ic.child_ingredient_id = $1 AND ic.organization_id = $2
ORDER BY ic.created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, childIngredientID, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var compositions []*entities.IngredientComposition
for rows.Next() {
composition := &entities.IngredientComposition{}
parentIngredient := &entities.Ingredient{}
childIngredient := &entities.Ingredient{}
err := rows.Scan(
&composition.ID,
&composition.OrganizationID,
&composition.OutletID,
&composition.ParentIngredientID,
&composition.ChildIngredientID,
&composition.Quantity,
&composition.CreatedAt,
&composition.UpdatedAt,
&parentIngredient.ID,
&parentIngredient.OrganizationID,
&parentIngredient.OutletID,
&parentIngredient.Name,
&parentIngredient.UnitID,
&parentIngredient.Cost,
&parentIngredient.Stock,
&parentIngredient.IsSemiFinished,
&parentIngredient.IsActive,
&parentIngredient.Metadata,
&parentIngredient.CreatedAt,
&parentIngredient.UpdatedAt,
&childIngredient.ID,
&childIngredient.OrganizationID,
&childIngredient.OutletID,
&childIngredient.Name,
&childIngredient.UnitID,
&childIngredient.Cost,
&childIngredient.Stock,
&childIngredient.IsSemiFinished,
&childIngredient.IsActive,
&childIngredient.Metadata,
&childIngredient.CreatedAt,
&childIngredient.UpdatedAt,
)
if err != nil {
return nil, err
}
composition.ParentIngredient = parentIngredient
composition.ChildIngredient = childIngredient
compositions = append(compositions, composition)
}
return compositions, nil return compositions, nil
} }
func (r *IngredientCompositionRepository) Update(ctx context.Context, composition *entities.IngredientComposition) error { func (r *IngredientCompositionRepository) Update(ctx context.Context, composition *entities.IngredientComposition) error {
query := ` result := r.db.WithContext(ctx).
UPDATE ingredient_compositions Model(&entities.IngredientComposition{}).
SET outlet_id = $1, quantity = $2, updated_at = $3 Where("id = ? AND organization_id = ?", composition.ID, composition.OrganizationID).
WHERE id = $4 AND organization_id = $5 Select("outlet_id", "quantity", "updated_at").
` Updates(composition)
if result.Error != nil {
result, err := r.db.ExecContext(ctx, query, return result.Error
composition.OutletID,
composition.Quantity,
composition.UpdatedAt,
composition.ID,
composition.OrganizationID,
)
if err != nil {
return err
} }
if result.RowsAffected == 0 {
rowsAffected, err := result.RowsAffected() return fmt.Errorf("no rows affected")
if err != nil {
return err
} }
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil return nil
} }
func (r *IngredientCompositionRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { func (r *IngredientCompositionRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error {
query := `DELETE FROM ingredient_compositions WHERE id = $1 AND organization_id = $2` result := r.db.WithContext(ctx).
Where("id = ? AND organization_id = ?", id, organizationID).
result, err := r.db.ExecContext(ctx, query, id, organizationID) Delete(&entities.IngredientComposition{})
if err != nil { if result.Error != nil {
return err return result.Error
} }
if result.RowsAffected == 0 {
rowsAffected, err := result.RowsAffected() return fmt.Errorf("no rows affected")
if err != nil {
return err
} }
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil return nil
} }
func (r *IngredientCompositionRepository) DeleteByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) error {
return r.db.WithContext(ctx).
Where("parent_ingredient_id = ? AND organization_id = ?", parentIngredientID, organizationID).
Delete(&entities.IngredientComposition{}).Error
}
+12 -2
View File
@@ -24,7 +24,11 @@ func (r *IngredientRepository) Create(ctx context.Context, ingredient *entities.
func (r *IngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Ingredient, error) { func (r *IngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Ingredient, error) {
var ingredient entities.Ingredient var ingredient entities.Ingredient
err := r.db.WithContext(ctx).Preload("Unit").Where("id = ? AND organization_id = ?", id, organizationID).First(&ingredient).Error err := r.db.WithContext(ctx).
Preload("Unit").
Preload("Compositions.ChildIngredient.Unit").
Where("id = ? AND organization_id = ?", id, organizationID).
First(&ingredient).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -57,7 +61,13 @@ func (r *IngredientRepository) GetAll(ctx context.Context, organizationID uuid.U
// Get paginated results with Unit preloaded // Get paginated results with Unit preloaded
offset := (page - 1) * limit offset := (page - 1) * limit
err := query.Preload("Unit").Order("created_at DESC").Limit(limit).Offset(offset).Find(&ingredients).Error err := query.
Preload("Unit").
Preload("Compositions.ChildIngredient.Unit").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&ingredients).Error
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
+21 -1
View File
@@ -46,11 +46,13 @@ type Router struct {
customerAuthHandler *handler.CustomerAuthHandler customerAuthHandler *handler.CustomerAuthHandler
customerPointsHandler *handler.CustomerPointsHandler customerPointsHandler *handler.CustomerPointsHandler
spinGameHandler *handler.SpinGameHandler spinGameHandler *handler.SpinGameHandler
selfOrderHandler *handler.SelfOrderHandler
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
selfOrderAuthMiddleware *middleware.SelfOrderAuthMiddleware
} }
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router { func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, selfOrderHandler *handler.SelfOrderHandler, selfOrderAuthMiddleware *middleware.SelfOrderAuthMiddleware) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
@@ -89,6 +91,8 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
authMiddleware: authMiddleware, authMiddleware: authMiddleware,
customerAuthMiddleware: customerAuthMiddleware, customerAuthMiddleware: customerAuthMiddleware,
productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator), productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator),
selfOrderHandler: selfOrderHandler,
selfOrderAuthMiddleware: selfOrderAuthMiddleware,
} }
} }
@@ -145,6 +149,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.POST("/spin", r.spinGameHandler.PlaySpinGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
} }
selfOrder := v1.Group("/self-order")
{
selfOrder.POST("/session", r.selfOrderHandler.CreateSession)
}
selfOrderProtected := v1.Group("/self-order")
selfOrderProtected.Use(r.selfOrderAuthMiddleware.ValidateSelfOrderToken())
{
selfOrderProtected.GET("/menu", r.selfOrderHandler.GetMenu)
selfOrderProtected.GET("/categories", r.selfOrderHandler.ListCategories)
selfOrderProtected.POST("/order", r.selfOrderHandler.CreateOrder)
}
organizations := v1.Group("/organizations") organizations := v1.Group("/organizations")
{ {
organizations.POST("", r.organizationHandler.CreateOrganization) organizations.POST("", r.organizationHandler.CreateOrganization)
@@ -322,6 +339,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
ingredients.GET("/:id", r.ingredientHandler.GetByID) ingredients.GET("/:id", r.ingredientHandler.GetByID)
ingredients.PUT("/:id", r.ingredientHandler.Update) ingredients.PUT("/:id", r.ingredientHandler.Update)
ingredients.DELETE("/:id", r.ingredientHandler.Delete) ingredients.DELETE("/:id", r.ingredientHandler.Delete)
ingredients.POST("/:id/compositions", r.ingredientHandler.AddCompositions)
ingredients.PUT("/compositions/:composition_id", r.ingredientHandler.UpdateComposition)
ingredients.DELETE("/compositions/:composition_id", r.ingredientHandler.DeleteComposition)
} }
vendors := protected.Group("/vendors") vendors := protected.Group("/vendors")
+3
View File
@@ -13,4 +13,7 @@ type IngredientProcessor interface {
DeleteIngredient(ctx context.Context, id uuid.UUID) error DeleteIngredient(ctx context.Context, id uuid.UUID) error
GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error)
ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error)
UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error)
DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error)
AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error)
} }
+12
View File
@@ -36,3 +36,15 @@ func (s *IngredientServiceImpl) GetIngredientByID(ctx context.Context, id uuid.U
func (s *IngredientServiceImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) { func (s *IngredientServiceImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) {
return s.ingredientProcessor.ListIngredients(ctx, organizationID, outletID, page, limit, search) return s.ingredientProcessor.ListIngredients(ctx, organizationID, outletID, page, limit, search)
} }
func (s *IngredientServiceImpl) UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) {
return s.ingredientProcessor.UpdateComposition(ctx, id, req)
}
func (s *IngredientServiceImpl) DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) {
return s.ingredientProcessor.DeleteComposition(ctx, id)
}
func (s *IngredientServiceImpl) AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) {
return s.ingredientProcessor.AddCompositions(ctx, parentID, req)
}
+1 -1
View File
@@ -483,7 +483,7 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("order item ID is required for payment item %d", i+1) return fmt.Errorf("order item ID is required for payment item %d", i+1)
} }
if item.Amount <= 0 { if item.Amount < 0 {
return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1) return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1)
} }
@@ -0,0 +1,83 @@
package transformer
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
)
// IngredientEntityToResponse converts ingredient entity to response model
func IngredientEntityToResponse(ingredient *entities.Ingredient) *models.IngredientResponse {
if ingredient == nil {
return nil
}
m := mappers.MapIngredientEntityToModel(ingredient)
resp := &models.IngredientResponse{
ID: m.ID,
OrganizationID: m.OrganizationID,
OutletID: m.OutletID,
Name: m.Name,
UnitID: m.UnitID,
Cost: m.Cost,
Stock: m.Stock,
IsSemiFinished: m.IsSemiFinished,
IsActive: m.IsActive,
Metadata: m.Metadata,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Unit: m.Unit,
}
// Use preloaded compositions for semi-finished ingredients
if ingredient.IsSemiFinished && len(ingredient.Compositions) > 0 {
resp.Compositions = make([]*models.IngredientCompositionResponse, 0, len(ingredient.Compositions))
for _, c := range ingredient.Compositions {
resp.Compositions = append(resp.Compositions, CompositionEntityToResponse(&c))
}
}
return resp
}
// CompositionEntityToResponse converts composition entity to response model
func CompositionEntityToResponse(c *entities.IngredientComposition) *models.IngredientCompositionResponse {
if c == nil {
return nil
}
resp := &models.IngredientCompositionResponse{
ID: c.ID,
OutletID: c.OutletID,
ChildIngredientID: c.ChildIngredientID,
Quantity: c.Quantity,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.ChildIngredient != nil {
resp.ChildIngredient = mappers.MapIngredientEntityToResponse(c.ChildIngredient)
}
if c.ParentIngredient != nil {
resp.ParentIngredient = IngredientEntityToResponse(c.ParentIngredient)
}
return resp
}
// IngredientsToResponses converts slice of ingredient entities to response models
func IngredientsToResponses(ingredients []*entities.Ingredient) []models.IngredientResponse {
if ingredients == nil {
return []models.IngredientResponse{}
}
responses := make([]models.IngredientResponse, len(ingredients))
for i, ing := range ingredients {
response := IngredientEntityToResponse(ing)
if response != nil {
responses[i] = *response
}
}
return responses
}
+53
View File
@@ -7,6 +7,7 @@ import (
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
// GenerateCustomerTokens generates access and refresh tokens for customer // GenerateCustomerTokens generates access and refresh tokens for customer
@@ -85,3 +86,55 @@ func ExtractCustomerIDFromToken(token *jwt.Token) (string, error) {
return customerID, nil return customerID, nil
} }
func GenerateSelfOrderSessionToken(tableID uuid.UUID, customerName string, phone string, secret string, ttlMinutes int) (string, int64, error) {
now := time.Now()
expiresAt := now.Add(time.Duration(ttlMinutes) * time.Minute)
claims := jwt.MapClaims{
"table_id": tableID.String(),
"customer_name": customerName,
"phone": phone,
"session_id": uuid.New().String(),
"type": "self_order_access",
"iat": now.Unix(),
"exp": expiresAt.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
return "", 0, fmt.Errorf("failed to generate self-order session token: %w", err)
}
return tokenString, expiresAt.Unix(), nil
}
func ValidateSelfOrderToken(tokenString string, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse self-order token: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("invalid self-order token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid self-order token claims")
}
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "self_order_access" {
return nil, fmt.Errorf("invalid self-order token type")
}
return claims, nil
}
+123
View File
@@ -0,0 +1,123 @@
# Test Ingredient Composition Endpoints
## 1. Add Compositions (Bulk)
```bash
POST http://localhost:4000/api/v1/ingredients/{ingredient_id}/compositions
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30
Content-Type: application/json
{
"compositions": [
{
"child_ingredient_id": "CHILD_INGREDIENT_UUID_1",
"quantity": 2.5
},
{
"child_ingredient_id": "CHILD_INGREDIENT_UUID_2",
"quantity": 1.0
}
]
}
```
**Expected Response:**
```json
{
"success": true,
"data": {
"parent_ingredient": {
"id": "uuid",
"name": "Semi-Finished Product",
"cost": 15.50,
"compositions": [...]
},
"compositions": [
{
"id": "COMPOSITION_ID_1",
"child_ingredient_id": "...",
"quantity": 2.5,
"child_ingredient": {...},
"parent_ingredient": {...}
}
]
}
}
```
## 2. Update Composition
```bash
PUT http://localhost:4000/api/v1/ingredients/compositions/{composition_id}
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30
Content-Type: application/json
{
"quantity": 3.5
}
```
**Expected Response:**
```json
{
"success": true,
"data": {
"id": "composition_id",
"child_ingredient_id": "...",
"quantity": 3.5,
"child_ingredient": {...},
"parent_ingredient": {
"id": "...",
"cost": 18.75,
"compositions": [...]
}
}
}
```
## 3. Delete Composition
```bash
DELETE http://localhost:4000/api/v1/ingredients/compositions/{composition_id}
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30
```
**Expected Response:**
```json
{
"success": true,
"data": {
"id": "parent_ingredient_id",
"name": "Semi-Finished Product",
"cost": 12.25,
"compositions": [
// remaining compositions after deletion
]
}
}
```
## Steps to Test:
1. **Get a semi-finished ingredient ID** (or create one first)
2. **Get child ingredient IDs** (raw ingredients to add as compositions)
3. **Add compositions** using the bulk endpoint
4. **Copy composition ID** from the response
5. **Update or Delete** using that composition ID
## cURL Commands:
### Delete Composition:
```bash
curl --request DELETE \
--url 'http://localhost:4000/api/v1/ingredients/compositions/YOUR_COMPOSITION_ID_HERE' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZW1haWwiOiJhZG1pbkBnb2t1bmEuaWQiLCJyb2xlIjoiYWRtaW4iLCJvcmdhbml6YXRpb25faWQiOiIwMzIwNWQ4Zi0yYzA2LTQ1MTMtYTk1Mi0yMWQxMTA5Zjc4ZmYiLCJpc3MiOiJhcHNrZWwtcG9zIiwic3ViIjoiNmM2ZDI0MjAtNzA0MS00ZWE5LWE3MjYtYTM0MDAxNGJiMjRkIiwiZXhwIjoxNzg1OTE0ODQ4LCJuYmYiOjE3NzcyNzQ4NDgsImlhdCI6MTc3NzI3NDg0OH0.prO4mU1zjVUZLgi5c8hj10A6ODETCMjnEP8wUQikZ30'
```
## Notes:
- Replace `{ingredient_id}` with actual parent ingredient UUID
- Replace `{composition_id}` with actual composition UUID
- Replace `CHILD_INGREDIENT_UUID_1` and `CHILD_INGREDIENT_UUID_2` with actual child ingredient UUIDs
- Parent ingredient must have `is_semi_finished: true`
- All responses now include the updated parent ingredient with recalculated cost