diff --git a/internal/app/app.go b/internal/app/app.go index be33f18..60af15d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -76,6 +76,8 @@ func (a *App) Initialize(cfg *config.Config) error { services.analyticsService, services.tableService, validators.tableValidator, + services.unitService, + services.ingredientService, ) return nil @@ -138,6 +140,8 @@ type repositories struct { customerRepo *repository.CustomerRepository analyticsRepo *repository.AnalyticsRepositoryImpl tableRepo *repository.TableRepository + unitRepo *repository.UnitRepository + ingredientRepo *repository.IngredientRepository } func (a *App) initRepositories() *repositories { @@ -159,6 +163,8 @@ func (a *App) initRepositories() *repositories { customerRepo: repository.NewCustomerRepository(a.db), analyticsRepo: repository.NewAnalyticsRepositoryImpl(a.db), tableRepo: repository.NewTableRepository(a.db), + unitRepo: repository.NewUnitRepository(a.db), + ingredientRepo: repository.NewIngredientRepository(a.db), } } @@ -177,6 +183,8 @@ type processors struct { customerProcessor *processor.CustomerProcessor analyticsProcessor *processor.AnalyticsProcessorImpl tableProcessor *processor.TableProcessor + unitProcessor *processor.UnitProcessorImpl + ingredientProcessor *processor.IngredientProcessorImpl } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -197,6 +205,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), + unitProcessor: processor.NewUnitProcessor(repos.unitRepo), + ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), } } @@ -216,6 +226,8 @@ type services struct { customerService service.CustomerService analyticsService *service.AnalyticsServiceImpl tableService *service.TableServiceImpl + unitService *service.UnitServiceImpl + ingredientService *service.IngredientServiceImpl } func (a *App) initServices(processors *processors, cfg *config.Config) *services { @@ -235,6 +247,8 @@ func (a *App) initServices(processors *processors, cfg *config.Config) *services var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor) analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor) tableService := service.NewTableService(processors.tableProcessor, transformer.NewTableTransformer()) + unitService := service.NewUnitService(processors.unitProcessor) + ingredientService := service.NewIngredientService(processors.ingredientProcessor) return &services{ userService: service.NewUserService(processors.userProcessor), @@ -252,6 +266,8 @@ func (a *App) initServices(processors *processors, cfg *config.Config) *services customerService: customerService, analyticsService: analyticsService, tableService: tableService, + unitService: unitService, + ingredientService: ingredientService, } } diff --git a/internal/appcontext/context_info.go b/internal/appcontext/context_info.go index 7261b74..63f6365 100644 --- a/internal/appcontext/context_info.go +++ b/internal/appcontext/context_info.go @@ -20,7 +20,7 @@ type ContextInfo struct { CorrelationID string UserID uuid.UUID OrganizationID uuid.UUID - OutletID string + OutletID uuid.UUID AppVersion string AppID string AppType string @@ -61,7 +61,7 @@ func FromGinContext(ctx context.Context) *ContextInfo { return &ContextInfo{ CorrelationID: value(ctx, CorrelationIDKey), UserID: uuidValue(ctx, UserIDKey), - OutletID: value(ctx, OutletIDKey), + OutletID: uuidValue(ctx, OutletIDKey), OrganizationID: uuidValue(ctx, OrganizationIDKey), AppVersion: value(ctx, AppVersionKey), AppID: value(ctx, AppIDKey), diff --git a/internal/contract/ingredient_composition_contract.go b/internal/contract/ingredient_composition_contract.go new file mode 100644 index 0000000..6c91bab --- /dev/null +++ b/internal/contract/ingredient_composition_contract.go @@ -0,0 +1,16 @@ +package contract + +import ( + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type IngredientCompositionContract interface { + Create(request *models.CreateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) + GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) + GetByParentIngredientID(parentIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error) + GetByChildIngredientID(childIngredientID uuid.UUID, organizationID uuid.UUID) ([]*models.IngredientCompositionResponse, error) + Update(id uuid.UUID, request *models.UpdateIngredientCompositionRequest, organizationID uuid.UUID) (*models.IngredientCompositionResponse, error) + Delete(id uuid.UUID, organizationID uuid.UUID) error +} diff --git a/internal/contract/ingredient_contract.go b/internal/contract/ingredient_contract.go new file mode 100644 index 0000000..9f49460 --- /dev/null +++ b/internal/contract/ingredient_contract.go @@ -0,0 +1,16 @@ +package contract + +import ( + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type IngredientContract interface { + Create(request *models.CreateIngredientRequest, organizationID uuid.UUID) (*models.IngredientResponse, error) + GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.IngredientResponse, error) + GetAll(organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string, isSemiFinished *bool) (*models.PaginatedResponse[models.IngredientResponse], error) + Update(id uuid.UUID, request *models.UpdateIngredientRequest, organizationID uuid.UUID) (*models.IngredientResponse, error) + Delete(id uuid.UUID, organizationID uuid.UUID) error + UpdateStock(id uuid.UUID, quantity float64, organizationID uuid.UUID) (*models.IngredientResponse, error) +} diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 4da7d9f..1b16142 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -9,6 +9,7 @@ import ( type CreateOrderRequest struct { OutletID uuid.UUID `json:"outlet_id" validate:"required"` UserID uuid.UUID `json:"user_id" validate:"required"` + CustomerID *uuid.UUID `json:"customer_id"` TableNumber *string `json:"table_number,omitempty" validate:"omitempty,max=50"` OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000"` diff --git a/internal/contract/payment_method_contract.go b/internal/contract/payment_method_contract.go index 0fe2233..9153b7a 100644 --- a/internal/contract/payment_method_contract.go +++ b/internal/contract/payment_method_contract.go @@ -7,7 +7,8 @@ import ( ) type CreatePaymentMethodRequest struct { - OrganizationID uuid.UUID `json:"organization_id" validate:"required"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id""'` Name string `json:"name" validate:"required,min=1,max=100"` Type string `json:"type" validate:"required,oneof=cash card digital_wallet qr edc"` Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"` diff --git a/internal/contract/product_ingredient_contract.go b/internal/contract/product_ingredient_contract.go new file mode 100644 index 0000000..7e4169b --- /dev/null +++ b/internal/contract/product_ingredient_contract.go @@ -0,0 +1,17 @@ +package contract + +import ( + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type ProductIngredientContract interface { + Create(request *models.CreateProductIngredientRequest, organizationID uuid.UUID) (*models.ProductIngredientResponse, error) + GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.ProductIngredientResponse, error) + GetByProductID(productID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductIngredientResponse, error) + GetByIngredientID(ingredientID uuid.UUID, organizationID uuid.UUID) ([]*models.ProductIngredientResponse, error) + Update(id uuid.UUID, request *models.UpdateProductIngredientRequest, organizationID uuid.UUID) (*models.ProductIngredientResponse, error) + Delete(id uuid.UUID, organizationID uuid.UUID) error + BulkCreate(productID uuid.UUID, ingredients []models.CreateProductIngredientRequest, organizationID uuid.UUID) ([]*models.ProductIngredientResponse, error) +} diff --git a/internal/contract/unit_contract.go b/internal/contract/unit_contract.go new file mode 100644 index 0000000..18c4d5b --- /dev/null +++ b/internal/contract/unit_contract.go @@ -0,0 +1,15 @@ +package contract + +import ( + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type UnitContract interface { + Create(request *models.CreateUnitRequest, organizationID uuid.UUID) (*models.UnitResponse, error) + GetByID(id uuid.UUID, organizationID uuid.UUID) (*models.UnitResponse, error) + GetAll(organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.UnitResponse], error) + Update(id uuid.UUID, request *models.UpdateUnitRequest, organizationID uuid.UUID) (*models.UnitResponse, error) + Delete(id uuid.UUID, organizationID uuid.UUID) error +} diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go new file mode 100644 index 0000000..9fa3b9f --- /dev/null +++ b/internal/entities/ingredient.go @@ -0,0 +1,23 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Ingredient struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` + Name string `gorm:"not null;size:255" json:"name"` + UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` + Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` + IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` + IsActive bool `gorm:"default:true" json:"is_active"` + Metadata map[string]any `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` +} diff --git a/internal/entities/ingredient_composition.go b/internal/entities/ingredient_composition.go new file mode 100644 index 0000000..fb3a0ff --- /dev/null +++ b/internal/entities/ingredient_composition.go @@ -0,0 +1,20 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type IngredientComposition struct { + ID uuid.UUID `json:"id" db:"id"` + OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` + ParentIngredientID uuid.UUID `json:"parent_ingredient_id" db:"parent_ingredient_id"` + ChildIngredientID uuid.UUID `json:"child_ingredient_id" db:"child_ingredient_id"` + Quantity float64 `json:"quantity" db:"quantity"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` + ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` +} diff --git a/internal/entities/inventory_movement.go b/internal/entities/inventory_movement.go index ae8052c..da07c4d 100644 --- a/internal/entities/inventory_movement.go +++ b/internal/entities/inventory_movement.go @@ -38,13 +38,14 @@ type InventoryMovement struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` - ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"` + ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"` + ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT" MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"` - Quantity int `gorm:"not null" json:"quantity" validate:"required"` - PreviousQuantity int `gorm:"not null" json:"previous_quantity" validate:"required"` - NewQuantity int `gorm:"not null" json:"new_quantity" validate:"required"` - UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"` - TotalCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"total_cost"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"` + PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"` + NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"` + UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"` + TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"` ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` @@ -57,7 +58,8 @@ type InventoryMovement struct { Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` - Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` + Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` diff --git a/internal/entities/product.go b/internal/entities/product.go index aa98cf9..9e52af2 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -8,27 +8,31 @@ import ( ) type Product struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` - CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"` - SKU *string `gorm:"size:100;index" json:"sku"` - Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` - Description *string `gorm:"type:text" json:"description"` - Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"` - Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"` - BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"` - ImageURL *string `gorm:"size:500" json:"image_url"` - PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - IsActive bool `gorm:"default:true" json:"is_active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + 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" validate:"required"` + CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"` + SKU *string `gorm:"size:100;index" json:"sku"` + Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` + Description *string `gorm:"type:text" json:"description"` + Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"` + Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"` + BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"` + ImageURL *string `gorm:"size:500" json:"image_url"` + PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` + HasIngredients bool `gorm:"default:false" json:"has_ingredients"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` - Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"` - Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"` - OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"` + Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` + ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"` + ProductIngredients []ProductIngredient `gorm:"foreignKey:ProductID" json:"product_ingredients,omitempty"` + Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"` + OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"` } func (p *Product) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/product_ingredient.go b/internal/entities/product_ingredient.go new file mode 100644 index 0000000..4a31447 --- /dev/null +++ b/internal/entities/product_ingredient.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type ProductIngredient struct { + ID uuid.UUID `json:"id" db:"id"` + OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` + ProductID uuid.UUID `json:"product_id" db:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"` + Quantity float64 `json:"quantity" db:"quantity"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + + // Relations + Product *Product `json:"product,omitempty"` + Ingredient *Ingredient `json:"ingredient,omitempty"` +} diff --git a/internal/entities/unit.go b/internal/entities/unit.go new file mode 100644 index 0000000..e3bf997 --- /dev/null +++ b/internal/entities/unit.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Unit struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` + Name string `gorm:"not null;size:255" json:"name"` + Abbreviation *string `gorm:"size:50" json:"abbreviation"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} diff --git a/internal/handler/ingredient_handler.go b/internal/handler/ingredient_handler.go new file mode 100644 index 0000000..fbbdee2 --- /dev/null +++ b/internal/handler/ingredient_handler.go @@ -0,0 +1,177 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/util" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type IngredientHandler struct { + ingredientService IngredientService +} + +func NewIngredientHandler(ingredientService IngredientService) *IngredientHandler { + return &IngredientHandler{ + ingredientService: ingredientService, + } +} + +func (h *IngredientHandler) Create(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var request models.CreateIngredientRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("IngredientHandler::Create -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") + return + } + + request.OrganizationID = contextInfo.OrganizationID + + ingredientResponse, err := h.ingredientService.CreateIngredient(ctx, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Create -> Failed to create ingredient from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Create") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Create") +} + +func (h *IngredientHandler) GetByID(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Invalid ingredient ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") + return + } + + ingredientResponse, err := h.ingredientService.GetIngredientByID(ctx, id) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetByID -> Failed to get ingredient from service") + validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Ingredient not found") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetByID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetByID") +} + +func (h *IngredientHandler) GetAll(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + // Get query parameters + pageStr := c.DefaultQuery("page", "1") + limitStr := c.DefaultQuery("limit", "10") + search := c.Query("search") + outletIDStr := c.Query("outlet_id") + + page, err := strconv.Atoi(pageStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid page parameter") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + return + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid limit parameter") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + return + } + + var outletID *uuid.UUID + if outletIDStr != "" { + parsedOutletID, err := uuid.Parse(outletIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Invalid outlet ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + return + } + outletID = &parsedOutletID + } + + ingredientResponse, err := h.ingredientService.ListIngredients(ctx, contextInfo.OrganizationID, outletID, page, limit, search) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::GetAll -> Failed to get ingredients from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get ingredients") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::GetAll") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::GetAll") +} + +func (h *IngredientHandler) Update(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Invalid ingredient ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + return + } + + var request models.UpdateIngredientRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + return + } + + ingredientResponse, err := h.ingredientService.UpdateIngredient(ctx, id, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Update -> Failed to update ingredient from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Update") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(ingredientResponse), "IngredientHandler::Update") +} + +func (h *IngredientHandler) Delete(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Invalid ingredient ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ingredient ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete") + return + } + + err = h.ingredientService.DeleteIngredient(ctx, id) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("IngredientHandler::Delete -> Failed to delete ingredient from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "IngredientHandler::Delete") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Ingredient deleted successfully", + }), "IngredientHandler::Delete") +} diff --git a/internal/handler/ingredient_service.go b/internal/handler/ingredient_service.go new file mode 100644 index 0000000..5405a3b --- /dev/null +++ b/internal/handler/ingredient_service.go @@ -0,0 +1,16 @@ +package handler + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type IngredientService interface { + CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) + UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) + DeleteIngredient(ctx context.Context, id uuid.UUID) error + GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) + ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) +} diff --git a/internal/handler/inventory_movement_service.go b/internal/handler/inventory_movement_service.go new file mode 100644 index 0000000..bb40c85 --- /dev/null +++ b/internal/handler/inventory_movement_service.go @@ -0,0 +1,14 @@ +package handler + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type InventoryMovementService interface { + CreateInventoryMovement(ctx context.Context, req *models.CreateInventoryMovementRequest) (*models.InventoryMovementResponse, error) + GetInventoryMovementByID(ctx context.Context, id uuid.UUID) (*models.InventoryMovementResponse, error) + ListInventoryMovements(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.InventoryMovementResponse], error) +} diff --git a/internal/handler/payment_method_handler.go b/internal/handler/payment_method_handler.go index 571e0d9..0e24774 100644 --- a/internal/handler/payment_method_handler.go +++ b/internal/handler/payment_method_handler.go @@ -49,6 +49,9 @@ func (h *PaymentMethodHandler) CreatePaymentMethod(c *gin.Context) { return } + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = contextInfo.OutletID + paymentMethodResponse := h.paymentMethodService.CreatePaymentMethod(ctx, contextInfo, &req) if paymentMethodResponse.HasErrors() { errorResp := paymentMethodResponse.GetErrors()[0] diff --git a/internal/handler/unit_handler.go b/internal/handler/unit_handler.go new file mode 100644 index 0000000..160a856 --- /dev/null +++ b/internal/handler/unit_handler.go @@ -0,0 +1,199 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/util" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UnitHandler struct { + unitService UnitService +} + +func NewUnitHandler(unitService UnitService) *UnitHandler { + return &UnitHandler{ + unitService: unitService, + } +} + +func (h *UnitHandler) Create(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var request models.CreateUnitRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("UnitHandler::Create -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Create") + return + } + + request.OrganizationID = contextInfo.OrganizationID + + unitResponse, err := h.unitService.CreateUnit(ctx, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Create -> Failed to create unit from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Create") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(unitResponse), "UnitHandler::Create") +} + +func (h *UnitHandler) GetByID(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetByID -> Invalid unit ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid unit ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetByID") + return + } + + unitResponse, err := h.unitService.GetUnitByID(ctx, id) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetByID -> Failed to get unit from service") + validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "Unit not found") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetByID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(unitResponse), "UnitHandler::GetByID") +} + +func (h *UnitHandler) GetAll(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + pageStr := c.DefaultQuery("page", "1") + limitStr := c.DefaultQuery("limit", "10") + search := c.Query("search") + outletIDStr := c.Query("outlet_id") + + page, err := strconv.Atoi(pageStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetAll -> Invalid page parameter") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid page parameter") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetAll") + return + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetAll -> Invalid limit parameter") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid limit parameter") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetAll") + return + } + + var outletID *uuid.UUID + if outletIDStr != "" { + parsedOutletID, err := uuid.Parse(outletIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetAll -> Invalid outlet ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetAll") + return + } + outletID = &parsedOutletID + } + + unitResponse, err := h.unitService.ListUnits(ctx, contextInfo.OrganizationID, outletID, page, limit, search) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::GetAll -> Failed to get units from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, "Failed to get units") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::GetAll") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(unitResponse), "UnitHandler::GetAll") +} + +// UpdateUnit godoc +// @Summary Update unit +// @Description Update an existing unit +// @Tags units +// @Accept json +// @Produce json +// @Param id path string true "Unit ID" +// @Param request body models.UpdateUnitRequest true "Update unit request" +// @Success 200 {object} contract.Response{data=models.UnitResponse} +// @Failure 400 {object} contract.Response +// @Failure 404 {object} contract.Response +// @Failure 500 {object} contract.Response +// @Router /units/{id} [put] +func (h *UnitHandler) Update(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Update -> Invalid unit ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid unit ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Update") + return + } + + var request models.UpdateUnitRequest + if err := c.ShouldBindJSON(&request); err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Update -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Update") + return + } + + unitResponse, err := h.unitService.UpdateUnit(ctx, id, &request) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Update -> Failed to update unit from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Update") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(unitResponse), "UnitHandler::Update") +} + +// DeleteUnit godoc +// @Summary Delete unit +// @Description Delete a unit by its ID +// @Tags units +// @Produce json +// @Param id path string true "Unit ID" +// @Success 200 {object} contract.Response +// @Failure 404 {object} contract.Response +// @Failure 500 {object} contract.Response +// @Router /units/{id} [delete] +func (h *UnitHandler) Delete(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Delete -> Invalid unit ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid unit ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Delete") + return + } + + err = h.unitService.DeleteUnit(ctx, id) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("UnitHandler::Delete -> Failed to delete unit from service") + validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UnitHandler::Delete") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Unit deleted successfully", + }), "UnitHandler::Delete") +} diff --git a/internal/handler/unit_service.go b/internal/handler/unit_service.go new file mode 100644 index 0000000..45a1fd1 --- /dev/null +++ b/internal/handler/unit_service.go @@ -0,0 +1,16 @@ +package handler + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type UnitService interface { + CreateUnit(ctx context.Context, req *models.CreateUnitRequest) (*models.UnitResponse, error) + UpdateUnit(ctx context.Context, id uuid.UUID, req *models.UpdateUnitRequest) (*models.UnitResponse, error) + DeleteUnit(ctx context.Context, id uuid.UUID) error + GetUnitByID(ctx context.Context, id uuid.UUID) (*models.UnitResponse, error) + ListUnits(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.UnitResponse], error) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 32d86a8..4dc977a 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -1,6 +1,7 @@ package handler import ( + "apskel-pos-be/internal/appcontext" "net/http" "strconv" @@ -294,20 +295,8 @@ func (h *UserHandler) DeactivateUser(c *gin.Context) { } func (h *UserHandler) UpdateUserOutlet(c *gin.Context) { - userIDStr := c.Param("id") - userID, err := uuid.Parse(userIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUserOutlet -> Invalid user ID") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUserOutlet -> user ID validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) var req contract.UpdateUserOutletRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -316,14 +305,7 @@ func (h *UserHandler) UpdateUserOutlet(c *gin.Context) { return } - validationError, validationErrorCode = h.userValidator.ValidateUpdateUserOutletRequest(&req) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUserOutlet -> request validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - userResponse, err := h.userService.UpdateUserOutlet(c.Request.Context(), userID, &req) + userResponse, err := h.userService.UpdateUserOutlet(c.Request.Context(), contextInfo.UserID, &req) if err != nil { logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUserOutlet -> Failed to update user outlet from service") h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) diff --git a/internal/mappers/ingredient_composition_mapper.go b/internal/mappers/ingredient_composition_mapper.go new file mode 100644 index 0000000..8b776d7 --- /dev/null +++ b/internal/mappers/ingredient_composition_mapper.go @@ -0,0 +1,70 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func MapIngredientCompositionEntityToModel(entity *entities.IngredientComposition) *models.IngredientComposition { + if entity == nil { + return nil + } + + return &models.IngredientComposition{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + ParentIngredientID: entity.ParentIngredientID, + ChildIngredientID: entity.ChildIngredientID, + Quantity: entity.Quantity, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + ParentIngredient: MapIngredientEntityToModel(entity.ParentIngredient), + ChildIngredient: MapIngredientEntityToModel(entity.ChildIngredient), + } +} + +func MapIngredientCompositionModelToEntity(model *models.IngredientComposition) *entities.IngredientComposition { + if model == nil { + return nil + } + + return &entities.IngredientComposition{ + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + ParentIngredientID: model.ParentIngredientID, + ChildIngredientID: model.ChildIngredientID, + Quantity: model.Quantity, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + ParentIngredient: MapIngredientModelToEntity(model.ParentIngredient), + ChildIngredient: MapIngredientModelToEntity(model.ChildIngredient), + } +} + +func MapIngredientCompositionEntitiesToModels(entities []*entities.IngredientComposition) []*models.IngredientComposition { + if entities == nil { + return nil + } + + models := make([]*models.IngredientComposition, len(entities)) + for i, entity := range entities { + models[i] = MapIngredientCompositionEntityToModel(entity) + } + + return models +} + +func MapIngredientCompositionModelsToEntities(models []*models.IngredientComposition) []*entities.IngredientComposition { + if models == nil { + return nil + } + + entities := make([]*entities.IngredientComposition, len(models)) + for i, model := range models { + entities[i] = MapIngredientCompositionModelToEntity(model) + } + + return entities +} diff --git a/internal/mappers/ingredient_mapper.go b/internal/mappers/ingredient_mapper.go new file mode 100644 index 0000000..6d26cb7 --- /dev/null +++ b/internal/mappers/ingredient_mapper.go @@ -0,0 +1,76 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func MapIngredientEntityToModel(entity *entities.Ingredient) *models.Ingredient { + if entity == nil { + return nil + } + + return &models.Ingredient{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Name: entity.Name, + UnitID: entity.UnitID, + Cost: entity.Cost, + Stock: entity.Stock, + IsSemiFinished: entity.IsSemiFinished, + IsActive: entity.IsActive, + Metadata: entity.Metadata, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + Unit: MapUnitEntityToModel(entity.Unit), + } +} + +func MapIngredientModelToEntity(model *models.Ingredient) *entities.Ingredient { + if model == nil { + return nil + } + + return &entities.Ingredient{ + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + Name: model.Name, + UnitID: model.UnitID, + Cost: model.Cost, + Stock: model.Stock, + IsSemiFinished: model.IsSemiFinished, + IsActive: model.IsActive, + Metadata: model.Metadata, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + Unit: MapUnitModelToEntity(model.Unit), + } +} + +func MapIngredientEntitiesToModels(entities []*entities.Ingredient) []*models.Ingredient { + if entities == nil { + return nil + } + + models := make([]*models.Ingredient, len(entities)) + for i, entity := range entities { + models[i] = MapIngredientEntityToModel(entity) + } + + return models +} + +func MapIngredientModelsToEntities(models []*models.Ingredient) []*entities.Ingredient { + if models == nil { + return nil + } + + entities := make([]*entities.Ingredient, len(models)) + for i, model := range models { + entities[i] = MapIngredientModelToEntity(model) + } + + return entities +} diff --git a/internal/mappers/inventory_movement_mapper.go b/internal/mappers/inventory_movement_mapper.go index 60c6a58..d01f3e7 100644 --- a/internal/mappers/inventory_movement_mapper.go +++ b/internal/mappers/inventory_movement_mapper.go @@ -14,11 +14,12 @@ func InventoryMovementEntityToModel(entity *entities.InventoryMovement) *models. ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, - ProductID: entity.ProductID, + ItemID: entity.ItemID, + ItemType: entity.ItemType, MovementType: models.InventoryMovementType(entity.MovementType), - Quantity: entity.Quantity, - PreviousQuantity: entity.PreviousQuantity, - NewQuantity: entity.NewQuantity, + Quantity: int(entity.Quantity), + PreviousQuantity: int(entity.PreviousQuantity), + NewQuantity: int(entity.NewQuantity), UnitCost: entity.UnitCost, TotalCost: entity.TotalCost, ReferenceType: (*models.InventoryMovementReferenceType)(entity.ReferenceType), @@ -42,11 +43,12 @@ func InventoryMovementModelToEntity(model *models.InventoryMovement) *entities.I ID: model.ID, OrganizationID: model.OrganizationID, OutletID: model.OutletID, - ProductID: model.ProductID, + ItemID: model.ItemID, + ItemType: model.ItemType, MovementType: entities.InventoryMovementType(model.MovementType), - Quantity: model.Quantity, - PreviousQuantity: model.PreviousQuantity, - NewQuantity: model.NewQuantity, + Quantity: float64(model.Quantity), + PreviousQuantity: float64(model.PreviousQuantity), + NewQuantity: float64(model.NewQuantity), UnitCost: model.UnitCost, TotalCost: model.TotalCost, ReferenceType: (*entities.InventoryMovementReferenceType)(model.ReferenceType), @@ -70,11 +72,12 @@ func InventoryMovementEntityToResponse(entity *entities.InventoryMovement) *mode ID: entity.ID, OrganizationID: entity.OrganizationID, OutletID: entity.OutletID, - ProductID: entity.ProductID, + ItemID: entity.ItemID, + ItemType: entity.ItemType, MovementType: models.InventoryMovementType(entity.MovementType), - Quantity: entity.Quantity, - PreviousQuantity: entity.PreviousQuantity, - NewQuantity: entity.NewQuantity, + Quantity: int(entity.Quantity), + PreviousQuantity: int(entity.PreviousQuantity), + NewQuantity: int(entity.NewQuantity), UnitCost: entity.UnitCost, TotalCost: entity.TotalCost, ReferenceType: (*models.InventoryMovementReferenceType)(entity.ReferenceType), @@ -116,11 +119,12 @@ func CreateInventoryMovementRequestToEntity(req *models.CreateInventoryMovementR return &entities.InventoryMovement{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, - ProductID: req.ProductID, + ItemID: req.ItemID, + ItemType: req.ItemType, MovementType: entities.InventoryMovementType(req.MovementType), - Quantity: req.Quantity, - PreviousQuantity: previousQuantity, - NewQuantity: newQuantity, + Quantity: float64(req.Quantity), + PreviousQuantity: float64(previousQuantity), + NewQuantity: float64(newQuantity), UnitCost: req.UnitCost, TotalCost: float64(req.Quantity) * req.UnitCost, ReferenceType: (*entities.InventoryMovementReferenceType)(req.ReferenceType), diff --git a/internal/mappers/payment_method_mapper.go b/internal/mappers/payment_method_mapper.go index d4c8f5e..651e721 100644 --- a/internal/mappers/payment_method_mapper.go +++ b/internal/mappers/payment_method_mapper.go @@ -100,6 +100,7 @@ func PaymentMethodModelToEntity(model *models.PaymentMethod) *entities.PaymentMe func CreatePaymentMethodContractToModel(req *contract.CreatePaymentMethodRequest) *models.CreatePaymentMethodRequest { return &models.CreatePaymentMethodRequest{ OrganizationID: req.OrganizationID, + OutletID: req.OutletID, Name: req.Name, Type: constants.PaymentMethodType(req.Type), Processor: req.Processor, diff --git a/internal/mappers/product_ingredient_mapper.go b/internal/mappers/product_ingredient_mapper.go new file mode 100644 index 0000000..1f59e2f --- /dev/null +++ b/internal/mappers/product_ingredient_mapper.go @@ -0,0 +1,70 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func MapProductIngredientEntityToModel(entity *entities.ProductIngredient) *models.ProductIngredient { + if entity == nil { + return nil + } + + return &models.ProductIngredient{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + ProductID: entity.ProductID, + IngredientID: entity.IngredientID, + Quantity: entity.Quantity, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + Product: ProductEntityToModel(entity.Product), + Ingredient: MapIngredientEntityToModel(entity.Ingredient), + } +} + +func MapProductIngredientModelToEntity(model *models.ProductIngredient) *entities.ProductIngredient { + if model == nil { + return nil + } + + return &entities.ProductIngredient{ + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + ProductID: model.ProductID, + IngredientID: model.IngredientID, + Quantity: model.Quantity, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + Product: ProductModelToEntity(model.Product), + Ingredient: MapIngredientModelToEntity(model.Ingredient), + } +} + +func MapProductIngredientEntitiesToModels(entities []*entities.ProductIngredient) []*models.ProductIngredient { + if entities == nil { + return nil + } + + models := make([]*models.ProductIngredient, len(entities)) + for i, entity := range entities { + models[i] = MapProductIngredientEntityToModel(entity) + } + + return models +} + +func MapProductIngredientModelsToEntities(models []*models.ProductIngredient) []*entities.ProductIngredient { + if models == nil { + return nil + } + + entities := make([]*entities.ProductIngredient, len(models)) + for i, model := range models { + entities[i] = MapProductIngredientModelToEntity(model) + } + + return entities +} diff --git a/internal/mappers/unit_mapper.go b/internal/mappers/unit_mapper.go new file mode 100644 index 0000000..d07d205 --- /dev/null +++ b/internal/mappers/unit_mapper.go @@ -0,0 +1,66 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func MapUnitEntityToModel(entity *entities.Unit) *models.Unit { + if entity == nil { + return nil + } + + return &models.Unit{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Name: entity.Name, + Abbreviation: entity.Abbreviation, + IsActive: entity.IsActive, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func MapUnitModelToEntity(model *models.Unit) *entities.Unit { + if model == nil { + return nil + } + + return &entities.Unit{ + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + Name: model.Name, + Abbreviation: model.Abbreviation, + IsActive: model.IsActive, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} + +func MapUnitEntitiesToModels(entities []*entities.Unit) []*models.Unit { + if entities == nil { + return nil + } + + models := make([]*models.Unit, len(entities)) + for i, entity := range entities { + models[i] = MapUnitEntityToModel(entity) + } + + return models +} + +func MapUnitModelsToEntities(models []*models.Unit) []*entities.Unit { + if models == nil { + return nil + } + + entities := make([]*entities.Unit, len(models)) + for i, model := range models { + entities[i] = MapUnitModelToEntity(model) + } + + return entities +} diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go new file mode 100644 index 0000000..6a75c62 --- /dev/null +++ b/internal/models/ingredient.go @@ -0,0 +1,66 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Ingredient struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + UnitID uuid.UUID `json:"unit_id"` + Cost float64 `json:"cost"` + Stock float64 `json:"stock"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata map[string]any `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Unit *Unit `json:"unit,omitempty"` +} + +type CreateIngredientRequest struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name" validate:"required,min=1,max=255"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Cost float64 `json:"cost" validate:"min=0"` + Stock float64 `json:"stock" validate:"min=0"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata map[string]any `json:"metadata"` +} + +type UpdateIngredientRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name" validate:"required,min=1,max=255"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Cost float64 `json:"cost" validate:"min=0"` + Stock float64 `json:"stock" validate:"min=0"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata map[string]any `json:"metadata"` +} + +type IngredientResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + UnitID uuid.UUID `json:"unit_id"` + Cost float64 `json:"cost"` + Stock float64 `json:"stock"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata map[string]any `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Unit *Unit `json:"unit,omitempty"` +} diff --git a/internal/models/ingredient_composition.go b/internal/models/ingredient_composition.go new file mode 100644 index 0000000..9b310d7 --- /dev/null +++ b/internal/models/ingredient_composition.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type IngredientComposition struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ParentIngredientID uuid.UUID `json:"parent_ingredient_id"` + ChildIngredientID uuid.UUID `json:"child_ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` + ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` +} + +type CreateIngredientCompositionRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + ParentIngredientID uuid.UUID `json:"parent_ingredient_id" validate:"required"` + ChildIngredientID uuid.UUID `json:"child_ingredient_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type UpdateIngredientCompositionRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type IngredientCompositionResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ParentIngredientID uuid.UUID `json:"parent_ingredient_id"` + ChildIngredientID uuid.UUID `json:"child_ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + ParentIngredient *Ingredient `json:"parent_ingredient,omitempty"` + ChildIngredient *Ingredient `json:"child_ingredient,omitempty"` +} diff --git a/internal/models/inventory_movement.go b/internal/models/inventory_movement.go index 926bd92..7c787f6 100644 --- a/internal/models/inventory_movement.go +++ b/internal/models/inventory_movement.go @@ -37,7 +37,8 @@ type InventoryMovement struct { ID uuid.UUID OrganizationID uuid.UUID OutletID uuid.UUID - ProductID uuid.UUID + ItemID uuid.UUID + ItemType string MovementType InventoryMovementType Quantity int PreviousQuantity int @@ -58,7 +59,8 @@ type InventoryMovement struct { type CreateInventoryMovementRequest struct { OrganizationID uuid.UUID OutletID uuid.UUID - ProductID uuid.UUID + ItemID uuid.UUID + ItemType string MovementType InventoryMovementType Quantity int UnitCost float64 @@ -76,7 +78,8 @@ type InventoryMovementResponse struct { ID uuid.UUID OrganizationID uuid.UUID OutletID uuid.UUID - ProductID uuid.UUID + ItemID uuid.UUID + ItemType string MovementType InventoryMovementType Quantity int PreviousQuantity int @@ -98,7 +101,8 @@ type InventoryMovementResponse struct { type ListInventoryMovementsRequest struct { OrganizationID *uuid.UUID OutletID *uuid.UUID - ProductID *uuid.UUID + ItemID *uuid.UUID + ItemType *string MovementType *InventoryMovementType ReferenceType *InventoryMovementReferenceType ReferenceID *uuid.UUID diff --git a/internal/models/payment_method.go b/internal/models/payment_method.go index 519d288..398586d 100644 --- a/internal/models/payment_method.go +++ b/internal/models/payment_method.go @@ -21,6 +21,7 @@ type PaymentMethod struct { type CreatePaymentMethodRequest struct { OrganizationID uuid.UUID `validate:"required"` + OutletID uuid.UUID `validate:"required"` Name string `validate:"required,min=1,max=100"` Type constants.PaymentMethodType `validate:"required"` Processor *string `validate:"omitempty,max=100"` diff --git a/internal/models/product.go b/internal/models/product.go index 060c447..31c77c4 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -19,6 +19,8 @@ type Product struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + UnitID *uuid.UUID + HasIngredients bool Metadata map[string]interface{} IsActive bool CreatedAt time.Time @@ -47,6 +49,8 @@ type CreateProductRequest struct { BusinessType constants.BusinessType `validate:"required"` ImageURL *string `validate:"omitempty,max=500"` PrinterType *string `validate:"omitempty,max=50"` + UnitID *uuid.UUID `validate:"omitempty"` + HasIngredients bool `validate:"omitempty"` Metadata map[string]interface{} Variants []CreateProductVariantRequest `validate:"omitempty,dive"` // Stock management fields @@ -56,16 +60,18 @@ type CreateProductRequest struct { } type UpdateProductRequest struct { - CategoryID *uuid.UUID - SKU *string `validate:"omitempty,max=100"` - Name *string `validate:"omitempty,min=1,max=255"` - Description *string `validate:"omitempty,max=1000"` - Price *float64 `validate:"omitempty,min=0"` - Cost *float64 `validate:"omitempty,min=0"` - ImageURL *string `validate:"omitempty,max=500"` - PrinterType *string `validate:"omitempty,max=50"` - Metadata map[string]interface{} - IsActive *bool + CategoryID *uuid.UUID `validate:"omitempty"` + SKU *string `validate:"omitempty,max=100"` + Name *string `validate:"omitempty,min=1,max=255"` + Description *string `validate:"omitempty,max=1000"` + Price *float64 `validate:"omitempty,min=0"` + Cost *float64 `validate:"omitempty,min=0"` + ImageURL *string `validate:"omitempty,max=500"` + PrinterType *string `validate:"omitempty,max=50"` + UnitID *uuid.UUID `validate:"omitempty"` + HasIngredients *bool `validate:"omitempty"` + Metadata map[string]interface{} + IsActive *bool // Stock management fields ReorderLevel *int `validate:"omitempty,min=0"` // Update reorder level for all existing inventory records } @@ -97,6 +103,8 @@ type ProductResponse struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + UnitID *uuid.UUID + HasIngredients bool Metadata map[string]interface{} IsActive bool CreatedAt time.Time diff --git a/internal/models/product_ingredient.go b/internal/models/product_ingredient.go new file mode 100644 index 0000000..6cece33 --- /dev/null +++ b/internal/models/product_ingredient.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ProductIngredient struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Product *Product `json:"product,omitempty"` + Ingredient *Ingredient `json:"ingredient,omitempty"` +} + +type CreateProductIngredientRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id" validate:"required"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type UpdateProductIngredientRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type ProductIngredientResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relations + Product *Product `json:"product,omitempty"` + Ingredient *Ingredient `json:"ingredient,omitempty"` +} diff --git a/internal/models/unit.go b/internal/models/unit.go new file mode 100644 index 0000000..4dea9e9 --- /dev/null +++ b/internal/models/unit.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Unit struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + Abbreviation *string `json:"abbreviation"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateUnitRequest struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name" validate:"required,min=1,max=50"` + Abbreviation *string `json:"abbreviation" validate:"omitempty,max=10"` + IsActive bool `json:"is_active"` +} + +type UpdateUnitRequest struct { + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name" validate:"required,min=1,max=50"` + Abbreviation *string `json:"abbreviation" validate:"omitempty,max=10"` + IsActive bool `json:"is_active"` +} + +type UnitResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + Abbreviation *string `json:"abbreviation"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/processor/ingredient_processor.go b/internal/processor/ingredient_processor.go new file mode 100644 index 0000000..6f6faf5 --- /dev/null +++ b/internal/processor/ingredient_processor.go @@ -0,0 +1,238 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "context" + "time" + + "github.com/google/uuid" +) + +type IngredientProcessorImpl struct { + ingredientRepo IngredientRepository + unitRepo UnitRepository +} + +func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository) *IngredientProcessorImpl { + return &IngredientProcessorImpl{ + ingredientRepo: ingredientRepo, + unitRepo: unitRepo, + } +} + +func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { + // Validate unit exists + _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID) + if err != nil { + return nil, err + } + + // Create ingredient entity + ingredient := &entities.Ingredient{ + ID: uuid.New(), + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Name: req.Name, + UnitID: req.UnitID, + Cost: req.Cost, + Stock: req.Stock, + IsSemiFinished: req.IsSemiFinished, + IsActive: req.IsActive, + Metadata: req.Metadata, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Save to database + err = p.ingredientRepo.Create(ctx, ingredient) + if err != nil { + return nil, err + } + + // Get with relations + ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, ingredient.ID, req.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) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { + // For now, we'll need to get organizationID from context or request + // This is a limitation of the current interface design + organizationID := uuid.Nil // 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) { + // Set default values + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + + ingredients, total, err := p.ingredientRepo.GetAll(ctx, organizationID, outletID, page, limit, search, nil) + if err != nil { + return nil, err + } + + // Map to response models + ingredientModels := mappers.MapIngredientEntitiesToModels(ingredients) + ingredientResponses := make([]models.IngredientResponse, len(ingredientModels)) + + for i, ingredientModel := range ingredientModels { + ingredientResponses[i] = models.IngredientResponse{ + ID: ingredientModel.ID, + OrganizationID: ingredientModel.OrganizationID, + OutletID: ingredientModel.OutletID, + Name: ingredientModel.Name, + UnitID: ingredientModel.UnitID, + Cost: ingredientModel.Cost, + Stock: ingredientModel.Stock, + IsSemiFinished: ingredientModel.IsSemiFinished, + IsActive: ingredientModel.IsActive, + Metadata: ingredientModel.Metadata, + CreatedAt: ingredientModel.CreatedAt, + UpdatedAt: ingredientModel.UpdatedAt, + Unit: ingredientModel.Unit, + } + } + + // Create paginated response + paginatedResponse := &models.PaginatedResponse[models.IngredientResponse]{ + Data: ingredientResponses, + Pagination: models.Pagination{ + Page: page, + Limit: limit, + Total: int64(total), + TotalPages: (total + limit - 1) / limit, + }, + } + + return paginatedResponse, nil +} + +func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) { + // For now, we'll need to get organizationID from context or request + // This is a limitation of the current interface design + organizationID := uuid.Nil // This should come from context + + // Get existing ingredient + existingIngredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + + // Validate unit exists if changed + if req.UnitID != existingIngredient.UnitID { + _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID) + if err != nil { + return nil, err + } + } + + // Update fields + existingIngredient.OutletID = req.OutletID + existingIngredient.Name = req.Name + existingIngredient.UnitID = req.UnitID + existingIngredient.Cost = req.Cost + existingIngredient.Stock = req.Stock + existingIngredient.IsSemiFinished = req.IsSemiFinished + existingIngredient.IsActive = req.IsActive + existingIngredient.Metadata = req.Metadata + existingIngredient.UpdatedAt = time.Now() + + // Save to database + err = p.ingredientRepo.Update(ctx, existingIngredient) + if err != nil { + return nil, err + } + + // Get with relations + ingredientWithUnit, err := p.ingredientRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + + // Map to response + ingredientModel := mappers.MapIngredientEntityToModel(ingredientWithUnit) + response := &models.IngredientResponse{ + ID: ingredientModel.ID, + OrganizationID: ingredientModel.OrganizationID, + OutletID: ingredientModel.OutletID, + Name: ingredientModel.Name, + UnitID: ingredientModel.UnitID, + Cost: ingredientModel.Cost, + Stock: ingredientModel.Stock, + IsSemiFinished: ingredientModel.IsSemiFinished, + IsActive: ingredientModel.IsActive, + Metadata: ingredientModel.Metadata, + CreatedAt: ingredientModel.CreatedAt, + UpdatedAt: ingredientModel.UpdatedAt, + Unit: ingredientModel.Unit, + } + + return response, nil +} + +func (p *IngredientProcessorImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error { + // For now, we'll need to get organizationID from context or request + // This is a limitation of the current interface design + organizationID := uuid.Nil // This should come from context + + err := p.ingredientRepo.Delete(ctx, id, organizationID) + if err != nil { + return err + } + + return nil +} diff --git a/internal/processor/ingredient_repository.go b/internal/processor/ingredient_repository.go new file mode 100644 index 0000000..38dde9a --- /dev/null +++ b/internal/processor/ingredient_repository.go @@ -0,0 +1,17 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "github.com/google/uuid" +) + +type IngredientRepository interface { + Create(ctx context.Context, ingredient *entities.Ingredient) error + GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Ingredient, error) + GetAll(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string, isSemiFinished *bool) ([]*entities.Ingredient, int, error) + Update(ctx context.Context, ingredient *entities.Ingredient) error + Delete(ctx context.Context, id, organizationID uuid.UUID) error + UpdateStock(ctx context.Context, id uuid.UUID, newStock float64, organizationID uuid.UUID) error +} diff --git a/internal/processor/inventory_movement_processor.go b/internal/processor/inventory_movement_processor.go index 1130139..7770fee 100644 --- a/internal/processor/inventory_movement_processor.go +++ b/internal/processor/inventory_movement_processor.go @@ -47,8 +47,8 @@ func NewInventoryMovementProcessorImpl( } } -func (p *InventoryMovementProcessorImpl) CreateMovement(ctx context.Context, req *models.CreateInventoryMovementRequest) (*models.InventoryMovementResponse, error) { - currentInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, req.ProductID, req.OutletID) +func (p *InventoryMovementProcessorImpl) CreateInventoryMovement(ctx context.Context, req *models.CreateInventoryMovementRequest) (*models.InventoryMovementResponse, error) { + currentInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, req.ItemID, req.OutletID) if err != nil { return nil, fmt.Errorf("failed to get current inventory: %w", err) } @@ -59,11 +59,12 @@ func (p *InventoryMovementProcessorImpl) CreateMovement(ctx context.Context, req movement := &entities.InventoryMovement{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, - ProductID: req.ProductID, + ItemID: req.ItemID, + ItemType: req.ItemType, MovementType: entities.InventoryMovementType(req.MovementType), - Quantity: req.Quantity, - PreviousQuantity: previousQuantity, - NewQuantity: newQuantity, + Quantity: float64(req.Quantity), + PreviousQuantity: float64(previousQuantity), + NewQuantity: float64(newQuantity), UnitCost: req.UnitCost, TotalCost: float64(req.Quantity) * req.UnitCost, ReferenceType: (*entities.InventoryMovementReferenceType)(req.ReferenceType), @@ -89,7 +90,7 @@ func (p *InventoryMovementProcessorImpl) CreateMovement(ctx context.Context, req return response, nil } -func (p *InventoryMovementProcessorImpl) GetMovementByID(ctx context.Context, id uuid.UUID) (*models.InventoryMovementResponse, error) { +func (p *InventoryMovementProcessorImpl) GetInventoryMovementByID(ctx context.Context, id uuid.UUID) (*models.InventoryMovementResponse, error) { movement, err := p.movementRepo.GetWithRelations(ctx, id) if err != nil { return nil, fmt.Errorf("movement not found: %w", err) @@ -99,44 +100,29 @@ func (p *InventoryMovementProcessorImpl) GetMovementByID(ctx context.Context, id return response, nil } -func (p *InventoryMovementProcessorImpl) ListMovements(ctx context.Context, req *models.ListInventoryMovementsRequest) (*models.ListInventoryMovementsResponse, error) { - filters := make(map[string]interface{}) - if req.OrganizationID != nil { - filters["organization_id"] = *req.OrganizationID +func (p *InventoryMovementProcessorImpl) ListInventoryMovements(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.InventoryMovementResponse], error) { + // Set default values + if page < 1 { + page = 1 } - if req.OutletID != nil { - filters["outlet_id"] = *req.OutletID + if limit < 1 { + limit = 10 } - if req.ProductID != nil { - filters["product_id"] = *req.ProductID - } - if req.MovementType != nil { - filters["movement_type"] = string(*req.MovementType) - } - if req.ReferenceType != nil { - filters["reference_type"] = string(*req.ReferenceType) - } - if req.ReferenceID != nil { - filters["reference_id"] = *req.ReferenceID - } - if req.OrderID != nil { - filters["order_id"] = *req.OrderID - } - if req.PaymentID != nil { - filters["payment_id"] = *req.PaymentID - } - if req.UserID != nil { - filters["user_id"] = *req.UserID - } - if req.DateFrom != nil { - filters["date_from"] = *req.DateFrom - } - if req.DateTo != nil { - filters["date_to"] = *req.DateTo + if limit > 100 { + limit = 100 } - offset := (req.Page - 1) * req.Limit - movements, total, err := p.movementRepo.List(ctx, filters, req.Limit, offset) + filters := make(map[string]interface{}) + filters["organization_id"] = organizationID + if outletID != nil { + filters["outlet_id"] = *outletID + } + if search != "" { + filters["search"] = search + } + + offset := (page - 1) * limit + movements, total, err := p.movementRepo.List(ctx, filters, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list movements: %w", err) } @@ -150,19 +136,18 @@ func (p *InventoryMovementProcessorImpl) ListMovements(ctx context.Context, req } } - // Calculate total pages - totalPages := int(total) / req.Limit - if int(total)%req.Limit > 0 { - totalPages++ + // Create paginated response + paginatedResponse := &models.PaginatedResponse[models.InventoryMovementResponse]{ + Data: movementResponses, + Pagination: models.Pagination{ + Page: page, + Limit: limit, + Total: total, + TotalPages: int((total + int64(limit) - 1) / int64(limit)), + }, } - return &models.ListInventoryMovementsResponse{ - Movements: movementResponses, - TotalCount: int(total), - Page: req.Page, - Limit: req.Limit, - TotalPages: totalPages, - }, nil + return paginatedResponse, nil } func (p *InventoryMovementProcessorImpl) GetMovementsByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID, limit, offset int) (*models.ListInventoryMovementsResponse, error) { diff --git a/internal/processor/payment_method_processor.go b/internal/processor/payment_method_processor.go index cac2996..952ab86 100644 --- a/internal/processor/payment_method_processor.go +++ b/internal/processor/payment_method_processor.go @@ -31,7 +31,6 @@ func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRep } func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) { - // Check if payment method with same name already exists exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil) if err != nil { return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err) @@ -40,21 +39,16 @@ func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, re return nil, fmt.Errorf("payment method with name '%s' already exists for this organization", req.Name) } - // Map request to entity paymentMethodEntity := mappers.CreatePaymentMethodRequestToEntity(req) - - // Create payment method if err := p.paymentMethodRepo.Create(ctx, paymentMethodEntity); err != nil { return nil, fmt.Errorf("failed to create payment method: %w", err) } - // Get created payment method createdPaymentMethod, err := p.paymentMethodRepo.GetByID(ctx, paymentMethodEntity.ID) if err != nil { return nil, fmt.Errorf("failed to retrieve created payment method: %w", err) } - // Map entity to response response := mappers.PaymentMethodEntityToResponse(createdPaymentMethod) return response, nil } @@ -70,7 +64,6 @@ func (p *PaymentMethodProcessorImpl) GetPaymentMethodByID(ctx context.Context, i } func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req *models.ListPaymentMethodsRequest) (*models.ListPaymentMethodsResponse, error) { - // Build filters filters := make(map[string]interface{}) if req.OrganizationID != nil { filters["organization_id"] = *req.OrganizationID @@ -85,10 +78,8 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req filters["search"] = req.Search } - // Calculate offset offset := (req.Page - 1) * req.Limit - // Get payment methods paymentMethods, total, err := p.paymentMethodRepo.List(ctx, filters, req.Limit, offset) if err != nil { return nil, fmt.Errorf("failed to list payment methods: %w", err) diff --git a/internal/processor/unit_processor.go b/internal/processor/unit_processor.go new file mode 100644 index 0000000..73dcc2d --- /dev/null +++ b/internal/processor/unit_processor.go @@ -0,0 +1,166 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "context" + "time" + + "github.com/google/uuid" +) + +type UnitProcessorImpl struct { + unitRepo UnitRepository +} + +func NewUnitProcessor(unitRepo UnitRepository) *UnitProcessorImpl { + return &UnitProcessorImpl{ + unitRepo: unitRepo, + } +} + +func (p *UnitProcessorImpl) CreateUnit(ctx context.Context, req *models.CreateUnitRequest) (*models.UnitResponse, error) { + unit := &entities.Unit{ + ID: uuid.New(), + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Name: req.Name, + Abbreviation: req.Abbreviation, + IsActive: req.IsActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := p.unitRepo.Create(ctx, unit) + if err != nil { + return nil, err + } + + unitModel := mappers.MapUnitEntityToModel(unit) + response := &models.UnitResponse{ + ID: unitModel.ID, + OrganizationID: unitModel.OrganizationID, + OutletID: unitModel.OutletID, + Name: unitModel.Name, + Abbreviation: unitModel.Abbreviation, + IsActive: unitModel.IsActive, + CreatedAt: unitModel.CreatedAt, + UpdatedAt: unitModel.UpdatedAt, + } + + return response, nil +} + +func (p *UnitProcessorImpl) GetUnitByID(ctx context.Context, id uuid.UUID) (*models.UnitResponse, error) { + organizationID := uuid.Nil // This should come from context + + unit, err := p.unitRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + + unitModel := mappers.MapUnitEntityToModel(unit) + response := &models.UnitResponse{ + ID: unitModel.ID, + OrganizationID: unitModel.OrganizationID, + OutletID: unitModel.OutletID, + Name: unitModel.Name, + Abbreviation: unitModel.Abbreviation, + IsActive: unitModel.IsActive, + CreatedAt: unitModel.CreatedAt, + UpdatedAt: unitModel.UpdatedAt, + } + + return response, nil +} + +func (p *UnitProcessorImpl) ListUnits(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.UnitResponse], error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + + units, total, err := p.unitRepo.GetAll(ctx, organizationID, outletID, page, limit, search) + if err != nil { + return nil, err + } + + unitModels := mappers.MapUnitEntitiesToModels(units) + unitResponses := make([]models.UnitResponse, len(unitModels)) + + for i, unitModel := range unitModels { + unitResponses[i] = models.UnitResponse{ + ID: unitModel.ID, + OrganizationID: unitModel.OrganizationID, + OutletID: unitModel.OutletID, + Name: unitModel.Name, + Abbreviation: unitModel.Abbreviation, + IsActive: unitModel.IsActive, + CreatedAt: unitModel.CreatedAt, + UpdatedAt: unitModel.UpdatedAt, + } + } + + paginatedResponse := &models.PaginatedResponse[models.UnitResponse]{ + Data: unitResponses, + Pagination: models.Pagination{ + Page: page, + Limit: limit, + Total: int64(total), + TotalPages: (total + limit - 1) / limit, + }, + } + + return paginatedResponse, nil +} + +func (p *UnitProcessorImpl) UpdateUnit(ctx context.Context, id uuid.UUID, req *models.UpdateUnitRequest) (*models.UnitResponse, error) { + organizationID := uuid.Nil + + existingUnit, err := p.unitRepo.GetByID(ctx, id, organizationID) + if err != nil { + return nil, err + } + + existingUnit.OutletID = req.OutletID + existingUnit.Name = req.Name + existingUnit.Abbreviation = req.Abbreviation + existingUnit.IsActive = req.IsActive + existingUnit.UpdatedAt = time.Now() + + err = p.unitRepo.Update(ctx, existingUnit) + if err != nil { + return nil, err + } + + unitModel := mappers.MapUnitEntityToModel(existingUnit) + response := &models.UnitResponse{ + ID: unitModel.ID, + OrganizationID: unitModel.OrganizationID, + OutletID: unitModel.OutletID, + Name: unitModel.Name, + Abbreviation: unitModel.Abbreviation, + IsActive: unitModel.IsActive, + CreatedAt: unitModel.CreatedAt, + UpdatedAt: unitModel.UpdatedAt, + } + + return response, nil +} + +func (p *UnitProcessorImpl) DeleteUnit(ctx context.Context, id uuid.UUID) error { + organizationID := uuid.Nil + + err := p.unitRepo.Delete(ctx, id, organizationID) + if err != nil { + return err + } + + return nil +} diff --git a/internal/processor/unit_processor_test.go b/internal/processor/unit_processor_test.go new file mode 100644 index 0000000..2695327 --- /dev/null +++ b/internal/processor/unit_processor_test.go @@ -0,0 +1,153 @@ +package processor + +import ( + "apskel-pos-be/internal/models" + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockUnitRepository is a mock implementation of the unit repository +type MockUnitRepository struct { + mock.Mock +} + +func (m *MockUnitRepository) Create(ctx context.Context, unit *models.Unit) error { + args := m.Called(ctx, unit) + return args.Error(0) +} + +func (m *MockUnitRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*models.Unit, error) { + args := m.Called(ctx, id, organizationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Unit), args.Error(1) +} + +func (m *MockUnitRepository) GetAll(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) ([]*models.Unit, int, error) { + args := m.Called(ctx, organizationID, outletID, page, limit, search) + if args.Get(0) == nil { + return nil, args.Int(1), args.Error(2) + } + return args.Get(0).([]*models.Unit), args.Int(1), args.Error(2) +} + +func (m *MockUnitRepository) Update(ctx context.Context, unit *models.Unit) error { + args := m.Called(ctx, unit) + return args.Error(0) +} + +func (m *MockUnitRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + args := m.Called(ctx, id, organizationID) + return args.Error(0) +} + +func TestUnitProcessor_Create(t *testing.T) { + // Create mock repository + mockRepo := &MockUnitRepository{} + + // Create processor + processor := NewUnitProcessor(mockRepo) + + // Test data + organizationID := uuid.New() + request := &models.CreateUnitRequest{ + Name: "Gram", + Abbreviation: "g", + IsActive: true, + } + + // Mock expectations + mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*models.Unit")).Return(nil) + + // Execute + result, err := processor.Create(request, organizationID) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, request.Name, result.Name) + assert.Equal(t, request.Abbreviation, result.Abbreviation) + assert.Equal(t, request.IsActive, result.IsActive) + assert.Equal(t, organizationID, result.OrganizationID) + + mockRepo.AssertExpectations(t) +} + +func TestUnitProcessor_GetByID(t *testing.T) { + // Create mock repository + mockRepo := &MockUnitRepository{} + + // Create processor + processor := NewUnitProcessor(mockRepo) + + // Test data + unitID := uuid.New() + organizationID := uuid.New() + expectedUnit := &models.Unit{ + ID: unitID, + OrganizationID: organizationID, + Name: "Gram", + Abbreviation: "g", + IsActive: true, + } + + // Mock expectations + mockRepo.On("GetByID", mock.Anything, unitID, organizationID).Return(expectedUnit, nil) + + // Execute + result, err := processor.GetByID(unitID, organizationID) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, expectedUnit.ID, result.ID) + assert.Equal(t, expectedUnit.Name, result.Name) + + mockRepo.AssertExpectations(t) +} + +func TestUnitProcessor_GetAll(t *testing.T) { + // Create mock repository + mockRepo := &MockUnitRepository{} + + // Create processor + processor := NewUnitProcessor(mockRepo) + + // Test data + organizationID := uuid.New() + expectedUnits := []*models.Unit{ + { + ID: uuid.New(), + OrganizationID: organizationID, + Name: "Gram", + Abbreviation: "g", + IsActive: true, + }, + { + ID: uuid.New(), + OrganizationID: organizationID, + Name: "Liter", + Abbreviation: "L", + IsActive: true, + }, + } + + // Mock expectations + mockRepo.On("GetAll", mock.Anything, organizationID, (*uuid.UUID)(nil), 1, 10, "").Return(expectedUnits, 2, nil) + + // Execute + result, err := processor.GetAll(organizationID, nil, 1, 10, "") + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result.Data, 2) + assert.Equal(t, 2, result.Pagination.Total) + + mockRepo.AssertExpectations(t) +} diff --git a/internal/processor/unit_repository.go b/internal/processor/unit_repository.go new file mode 100644 index 0000000..9328e65 --- /dev/null +++ b/internal/processor/unit_repository.go @@ -0,0 +1,16 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "github.com/google/uuid" +) + +type UnitRepository interface { + Create(ctx context.Context, unit *entities.Unit) error + GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Unit, error) + GetAll(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) ([]*entities.Unit, int, error) + Update(ctx context.Context, unit *entities.Unit) error + Delete(ctx context.Context, id, organizationID uuid.UUID) error +} diff --git a/internal/repository/ingredient_composition_repository.go b/internal/repository/ingredient_composition_repository.go new file mode 100644 index 0000000..b1658c6 --- /dev/null +++ b/internal/repository/ingredient_composition_repository.go @@ -0,0 +1,287 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "database/sql" + + "github.com/google/uuid" +) + +type IngredientCompositionRepository struct { + db *sql.DB +} + +func NewIngredientCompositionRepository(db *sql.DB) *IngredientCompositionRepository { + return &IngredientCompositionRepository{db: db} +} + +func (r *IngredientCompositionRepository) Create(ctx context.Context, composition *entities.IngredientComposition) error { + query := ` + INSERT INTO ingredient_compositions (id, organization_id, outlet_id, parent_ingredient_id, child_ingredient_id, quantity, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ` + + _, err := r.db.ExecContext(ctx, query, + composition.ID, + composition.OrganizationID, + composition.OutletID, + composition.ParentIngredientID, + composition.ChildIngredientID, + composition.Quantity, + composition.CreatedAt, + composition.UpdatedAt, + ) + + return err +} + +func (r *IngredientCompositionRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.IngredientComposition, error) { + query := ` + SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, + pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, + ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at + FROM ingredient_compositions ic + LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id + LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id + WHERE ic.id = $1 AND ic.organization_id = $2 + ` + + composition := &entities.IngredientComposition{} + parentIngredient := &entities.Ingredient{} + childIngredient := &entities.Ingredient{} + + err := r.db.QueryRowContext(ctx, query, id, organizationID).Scan( + &composition.ID, + &composition.OrganizationID, + &composition.OutletID, + &composition.ParentIngredientID, + &composition.ChildIngredientID, + &composition.Quantity, + &composition.CreatedAt, + &composition.UpdatedAt, + &parentIngredient.ID, + &parentIngredient.OrganizationID, + &parentIngredient.OutletID, + &parentIngredient.Name, + &parentIngredient.UnitID, + &parentIngredient.Cost, + &parentIngredient.Stock, + &parentIngredient.IsSemiFinished, + &parentIngredient.IsActive, + &parentIngredient.Metadata, + &parentIngredient.CreatedAt, + &parentIngredient.UpdatedAt, + &childIngredient.ID, + &childIngredient.OrganizationID, + &childIngredient.OutletID, + &childIngredient.Name, + &childIngredient.UnitID, + &childIngredient.Cost, + &childIngredient.Stock, + &childIngredient.IsSemiFinished, + &childIngredient.IsActive, + &childIngredient.Metadata, + &childIngredient.CreatedAt, + &childIngredient.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + composition.ParentIngredient = parentIngredient + composition.ChildIngredient = childIngredient + return composition, nil +} + +func (r *IngredientCompositionRepository) GetByParentIngredientID(ctx context.Context, parentIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) { + query := ` + SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, + pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, + ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at + FROM ingredient_compositions ic + LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id + LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id + WHERE ic.parent_ingredient_id = $1 AND ic.organization_id = $2 + ORDER BY ic.created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, parentIngredientID, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var compositions []*entities.IngredientComposition + for rows.Next() { + composition := &entities.IngredientComposition{} + parentIngredient := &entities.Ingredient{} + childIngredient := &entities.Ingredient{} + + err := rows.Scan( + &composition.ID, + &composition.OrganizationID, + &composition.OutletID, + &composition.ParentIngredientID, + &composition.ChildIngredientID, + &composition.Quantity, + &composition.CreatedAt, + &composition.UpdatedAt, + &parentIngredient.ID, + &parentIngredient.OrganizationID, + &parentIngredient.OutletID, + &parentIngredient.Name, + &parentIngredient.UnitID, + &parentIngredient.Cost, + &parentIngredient.Stock, + &parentIngredient.IsSemiFinished, + &parentIngredient.IsActive, + &parentIngredient.Metadata, + &parentIngredient.CreatedAt, + &parentIngredient.UpdatedAt, + &childIngredient.ID, + &childIngredient.OrganizationID, + &childIngredient.OutletID, + &childIngredient.Name, + &childIngredient.UnitID, + &childIngredient.Cost, + &childIngredient.Stock, + &childIngredient.IsSemiFinished, + &childIngredient.IsActive, + &childIngredient.Metadata, + &childIngredient.CreatedAt, + &childIngredient.UpdatedAt, + ) + if err != nil { + return nil, err + } + + composition.ParentIngredient = parentIngredient + composition.ChildIngredient = childIngredient + compositions = append(compositions, composition) + } + + return compositions, nil +} + +func (r *IngredientCompositionRepository) GetByChildIngredientID(ctx context.Context, childIngredientID, organizationID uuid.UUID) ([]*entities.IngredientComposition, error) { + query := ` + SELECT ic.id, ic.organization_id, ic.outlet_id, ic.parent_ingredient_id, ic.child_ingredient_id, ic.quantity, ic.created_at, ic.updated_at, + pi.id, pi.organization_id, pi.outlet_id, pi.name, pi.unit_id, pi.cost, pi.stock, pi.is_semi_finished, pi.is_active, pi.metadata, pi.created_at, pi.updated_at, + ci.id, ci.organization_id, ci.outlet_id, ci.name, ci.unit_id, ci.cost, ci.stock, ci.is_semi_finished, ci.is_active, ci.metadata, ci.created_at, ci.updated_at + FROM ingredient_compositions ic + LEFT JOIN ingredients pi ON ic.parent_ingredient_id = pi.id + LEFT JOIN ingredients ci ON ic.child_ingredient_id = ci.id + WHERE ic.child_ingredient_id = $1 AND ic.organization_id = $2 + ORDER BY ic.created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, childIngredientID, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var compositions []*entities.IngredientComposition + for rows.Next() { + composition := &entities.IngredientComposition{} + parentIngredient := &entities.Ingredient{} + childIngredient := &entities.Ingredient{} + + err := rows.Scan( + &composition.ID, + &composition.OrganizationID, + &composition.OutletID, + &composition.ParentIngredientID, + &composition.ChildIngredientID, + &composition.Quantity, + &composition.CreatedAt, + &composition.UpdatedAt, + &parentIngredient.ID, + &parentIngredient.OrganizationID, + &parentIngredient.OutletID, + &parentIngredient.Name, + &parentIngredient.UnitID, + &parentIngredient.Cost, + &parentIngredient.Stock, + &parentIngredient.IsSemiFinished, + &parentIngredient.IsActive, + &parentIngredient.Metadata, + &parentIngredient.CreatedAt, + &parentIngredient.UpdatedAt, + &childIngredient.ID, + &childIngredient.OrganizationID, + &childIngredient.OutletID, + &childIngredient.Name, + &childIngredient.UnitID, + &childIngredient.Cost, + &childIngredient.Stock, + &childIngredient.IsSemiFinished, + &childIngredient.IsActive, + &childIngredient.Metadata, + &childIngredient.CreatedAt, + &childIngredient.UpdatedAt, + ) + if err != nil { + return nil, err + } + + composition.ParentIngredient = parentIngredient + composition.ChildIngredient = childIngredient + compositions = append(compositions, composition) + } + + return compositions, nil +} + +func (r *IngredientCompositionRepository) Update(ctx context.Context, composition *entities.IngredientComposition) error { + query := ` + UPDATE ingredient_compositions + SET outlet_id = $1, quantity = $2, updated_at = $3 + WHERE id = $4 AND organization_id = $5 + ` + + result, err := r.db.ExecContext(ctx, query, + composition.OutletID, + composition.Quantity, + composition.UpdatedAt, + composition.ID, + composition.OrganizationID, + ) + + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (r *IngredientCompositionRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + query := `DELETE FROM ingredient_compositions WHERE id = $1 AND organization_id = $2` + + result, err := r.db.ExecContext(ctx, query, id, organizationID) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} diff --git a/internal/repository/ingredient_repository.go b/internal/repository/ingredient_repository.go new file mode 100644 index 0000000..31e618e --- /dev/null +++ b/internal/repository/ingredient_repository.go @@ -0,0 +1,102 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "fmt" + + "gorm.io/gorm" + + "github.com/google/uuid" +) + +type IngredientRepository struct { + db *gorm.DB +} + +func NewIngredientRepository(db *gorm.DB) *IngredientRepository { + return &IngredientRepository{db: db} +} + +func (r *IngredientRepository) Create(ctx context.Context, ingredient *entities.Ingredient) error { + return r.db.WithContext(ctx).Create(ingredient).Error +} + +func (r *IngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Ingredient, error) { + var ingredient entities.Ingredient + err := r.db.WithContext(ctx).Preload("Unit").Where("id = ? AND organization_id = ?", id, organizationID).First(&ingredient).Error + if err != nil { + return nil, err + } + return &ingredient, nil +} + +func (r *IngredientRepository) GetAll(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string, isSemiFinished *bool) ([]*entities.Ingredient, int, error) { + var ingredients []*entities.Ingredient + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Ingredient{}).Where("organization_id = ?", organizationID) + + if outletID != nil { + query = query.Where("outlet_id = ?", *outletID) + } + + if search != "" { + searchValue := "%" + search + "%" + query = query.Where("name ILIKE ?", searchValue) + } + + if isSemiFinished != nil { + query = query.Where("is_semi_finished = ?", *isSemiFinished) + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results with Unit preloaded + offset := (page - 1) * limit + err := query.Preload("Unit").Order("created_at DESC").Limit(limit).Offset(offset).Find(&ingredients).Error + if err != nil { + return nil, 0, err + } + + return ingredients, int(total), nil +} + +func (r *IngredientRepository) Update(ctx context.Context, ingredient *entities.Ingredient) error { + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", ingredient.ID, ingredient.OrganizationID).Save(ingredient) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") + } + return nil +} + +func (r *IngredientRepository) UpdateStock(ctx context.Context, id uuid.UUID, quantity float64, organizationID uuid.UUID) error { + result := r.db.WithContext(ctx).Model(&entities.Ingredient{}). + Where("id = ? AND organization_id = ?", id, organizationID). + Update("stock", gorm.Expr("stock + ?", quantity)) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") + } + return nil +} + +func (r *IngredientRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", id, organizationID).Delete(&entities.Ingredient{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") + } + return nil +} diff --git a/internal/repository/payment_repository.go b/internal/repository/payment_repository.go index adf51fa..197a88f 100644 --- a/internal/repository/payment_repository.go +++ b/internal/repository/payment_repository.go @@ -150,11 +150,12 @@ func (r *PaymentRepositoryImpl) CreatePaymentWithInventoryMovement(ctx context.C movement := &entities.InventoryMovement{ OrganizationID: order.OrganizationID, OutletID: order.OutletID, - ProductID: item.ProductID, + ItemID: item.ProductID, + ItemType: "PRODUCT", MovementType: entities.InventoryMovementTypeSale, - Quantity: -item.Quantity, - PreviousQuantity: updatedInventory.Quantity + item.Quantity, // Add back the quantity that was subtracted - NewQuantity: updatedInventory.Quantity, + Quantity: float64(-item.Quantity), + PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted + NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, TotalCost: float64(item.Quantity) * item.UnitCost, ReferenceType: func() *entities.InventoryMovementReferenceType { @@ -222,11 +223,12 @@ func (r *PaymentRepositoryImpl) RefundPaymentWithInventoryMovement(ctx context.C movement := &entities.InventoryMovement{ OrganizationID: order.OrganizationID, OutletID: order.OutletID, - ProductID: item.ProductID, + ItemID: item.ProductID, + ItemType: "PRODUCT", MovementType: entities.InventoryMovementTypeRefund, - Quantity: refundedQuantity, - PreviousQuantity: updatedInventory.Quantity - refundedQuantity, // Subtract the quantity that was added - NewQuantity: updatedInventory.Quantity, + Quantity: float64(refundedQuantity), + PreviousQuantity: float64(updatedInventory.Quantity - refundedQuantity), // Subtract the quantity that was added + NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, TotalCost: float64(refundedQuantity) * item.UnitCost, ReferenceType: func() *entities.InventoryMovementReferenceType { diff --git a/internal/repository/product_ingredient_repository.go b/internal/repository/product_ingredient_repository.go new file mode 100644 index 0000000..b81579d --- /dev/null +++ b/internal/repository/product_ingredient_repository.go @@ -0,0 +1,309 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "database/sql" + + "github.com/google/uuid" +) + +type ProductIngredientRepository struct { + db *sql.DB +} + +func NewProductIngredientRepository(db *sql.DB) *ProductIngredientRepository { + return &ProductIngredientRepository{db: db} +} + +func (r *ProductIngredientRepository) Create(ctx context.Context, productIngredient *entities.ProductIngredient) error { + query := ` + INSERT INTO product_ingredients (id, organization_id, outlet_id, product_id, ingredient_id, quantity, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ` + + _, err := r.db.ExecContext(ctx, query, + productIngredient.ID, + productIngredient.OrganizationID, + productIngredient.OutletID, + productIngredient.ProductID, + productIngredient.IngredientID, + productIngredient.Quantity, + productIngredient.CreatedAt, + productIngredient.UpdatedAt, + ) + + return err +} + +func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) { + query := ` + SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at + FROM product_ingredients pi + LEFT JOIN products p ON pi.product_id = p.id + LEFT JOIN ingredients i ON pi.ingredient_id = i.id + WHERE pi.id = $1 AND pi.organization_id = $2 + ` + + productIngredient := &entities.ProductIngredient{} + product := &entities.Product{} + ingredient := &entities.Ingredient{} + + err := r.db.QueryRowContext(ctx, query, id, organizationID).Scan( + &productIngredient.ID, + &productIngredient.OrganizationID, + &productIngredient.OutletID, + &productIngredient.ProductID, + &productIngredient.IngredientID, + &productIngredient.Quantity, + &productIngredient.CreatedAt, + &productIngredient.UpdatedAt, + &product.ID, + &product.OrganizationID, + &product.CategoryID, + &product.SKU, + &product.Name, + &product.Description, + &product.Price, + &product.Cost, + &product.BusinessType, + &product.ImageURL, + &product.PrinterType, + &product.UnitID, + &product.HasIngredients, + &product.Metadata, + &product.IsActive, + &product.CreatedAt, + &product.UpdatedAt, + &ingredient.ID, + &ingredient.OrganizationID, + &ingredient.OutletID, + &ingredient.Name, + &ingredient.UnitID, + &ingredient.Cost, + &ingredient.Stock, + &ingredient.IsSemiFinished, + &ingredient.IsActive, + &ingredient.Metadata, + &ingredient.CreatedAt, + &ingredient.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + productIngredient.Product = product + productIngredient.Ingredient = ingredient + return productIngredient, nil +} + +func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) { + query := ` + SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at + FROM product_ingredients pi + LEFT JOIN products p ON pi.product_id = p.id + LEFT JOIN ingredients i ON pi.ingredient_id = i.id + WHERE pi.product_id = $1 AND pi.organization_id = $2 + ORDER BY pi.created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, productID, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var productIngredients []*entities.ProductIngredient + for rows.Next() { + productIngredient := &entities.ProductIngredient{} + product := &entities.Product{} + ingredient := &entities.Ingredient{} + + err := rows.Scan( + &productIngredient.ID, + &productIngredient.OrganizationID, + &productIngredient.OutletID, + &productIngredient.ProductID, + &productIngredient.IngredientID, + &productIngredient.Quantity, + &productIngredient.CreatedAt, + &productIngredient.UpdatedAt, + &product.ID, + &product.OrganizationID, + &product.CategoryID, + &product.SKU, + &product.Name, + &product.Description, + &product.Price, + &product.Cost, + &product.BusinessType, + &product.ImageURL, + &product.PrinterType, + &product.UnitID, + &product.HasIngredients, + &product.Metadata, + &product.IsActive, + &product.CreatedAt, + &product.UpdatedAt, + &ingredient.ID, + &ingredient.OrganizationID, + &ingredient.OutletID, + &ingredient.Name, + &ingredient.UnitID, + &ingredient.Cost, + &ingredient.Stock, + &ingredient.IsSemiFinished, + &ingredient.IsActive, + &ingredient.Metadata, + &ingredient.CreatedAt, + &ingredient.UpdatedAt, + ) + if err != nil { + return nil, err + } + + productIngredient.Product = product + productIngredient.Ingredient = ingredient + productIngredients = append(productIngredients, productIngredient) + } + + return productIngredients, nil +} + +func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) { + query := ` + SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at + FROM product_ingredients pi + LEFT JOIN products p ON pi.product_id = p.id + LEFT JOIN ingredients i ON pi.ingredient_id = i.id + WHERE pi.ingredient_id = $1 AND pi.organization_id = $2 + ORDER BY pi.created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, ingredientID, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var productIngredients []*entities.ProductIngredient + for rows.Next() { + productIngredient := &entities.ProductIngredient{} + product := &entities.Product{} + ingredient := &entities.Ingredient{} + + err := rows.Scan( + &productIngredient.ID, + &productIngredient.OrganizationID, + &productIngredient.OutletID, + &productIngredient.ProductID, + &productIngredient.IngredientID, + &productIngredient.Quantity, + &productIngredient.CreatedAt, + &productIngredient.UpdatedAt, + &product.ID, + &product.OrganizationID, + &product.CategoryID, + &product.SKU, + &product.Name, + &product.Description, + &product.Price, + &product.Cost, + &product.BusinessType, + &product.ImageURL, + &product.PrinterType, + &product.UnitID, + &product.HasIngredients, + &product.Metadata, + &product.IsActive, + &product.CreatedAt, + &product.UpdatedAt, + &ingredient.ID, + &ingredient.OrganizationID, + &ingredient.OutletID, + &ingredient.Name, + &ingredient.UnitID, + &ingredient.Cost, + &ingredient.Stock, + &ingredient.IsSemiFinished, + &ingredient.IsActive, + &ingredient.Metadata, + &ingredient.CreatedAt, + &ingredient.UpdatedAt, + ) + if err != nil { + return nil, err + } + + productIngredient.Product = product + productIngredient.Ingredient = ingredient + productIngredients = append(productIngredients, productIngredient) + } + + return productIngredients, nil +} + +func (r *ProductIngredientRepository) Update(ctx context.Context, productIngredient *entities.ProductIngredient) error { + query := ` + UPDATE product_ingredients + SET outlet_id = $1, quantity = $2, updated_at = $3 + WHERE id = $4 AND organization_id = $5 + ` + + result, err := r.db.ExecContext(ctx, query, + productIngredient.OutletID, + productIngredient.Quantity, + productIngredient.UpdatedAt, + productIngredient.ID, + productIngredient.OrganizationID, + ) + + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (r *ProductIngredientRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + query := `DELETE FROM product_ingredients WHERE id = $1 AND organization_id = $2` + + result, err := r.db.ExecContext(ctx, query, id, organizationID) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (r *ProductIngredientRepository) DeleteByProductID(ctx context.Context, productID, organizationID uuid.UUID) error { + query := `DELETE FROM product_ingredients WHERE product_id = $1 AND organization_id = $2` + + _, err := r.db.ExecContext(ctx, query, productID, organizationID) + return err +} diff --git a/internal/repository/unit_repository.go b/internal/repository/unit_repository.go new file mode 100644 index 0000000..02144be --- /dev/null +++ b/internal/repository/unit_repository.go @@ -0,0 +1,84 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "fmt" + + "gorm.io/gorm" + + "github.com/google/uuid" +) + +type UnitRepository struct { + db *gorm.DB +} + +func NewUnitRepository(db *gorm.DB) *UnitRepository { + return &UnitRepository{db: db} +} + +func (r *UnitRepository) Create(ctx context.Context, unit *entities.Unit) error { + return r.db.WithContext(ctx).Create(unit).Error +} + +func (r *UnitRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Unit, error) { + var unit entities.Unit + err := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", id, organizationID).First(&unit).Error + if err != nil { + return nil, err + } + return &unit, nil +} + +func (r *UnitRepository) GetAll(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) ([]*entities.Unit, int, error) { + var units []*entities.Unit + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Unit{}).Where("organization_id = ?", organizationID) + + if outletID != nil { + query = query.Where("outlet_id = ?", *outletID) + } + + if search != "" { + searchValue := "%" + search + "%" + query = query.Where("name ILIKE ? OR abbreviation ILIKE ?", searchValue, searchValue) + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&units).Error + if err != nil { + return nil, 0, err + } + + return units, int(total), nil +} + +func (r *UnitRepository) Update(ctx context.Context, unit *entities.Unit) error { + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", unit.ID, unit.OrganizationID).Save(unit) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") + } + return nil +} + +func (r *UnitRepository) Delete(ctx context.Context, id, organizationID uuid.UUID) error { + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", id, organizationID).Delete(&entities.Unit{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("no rows affected") + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index ae1876c..bdb87f8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -29,6 +29,8 @@ type Router struct { paymentMethodHandler *handler.PaymentMethodHandler analyticsHandler *handler.AnalyticsHandler tableHandler *handler.TableHandler + unitHandler *handler.UnitHandler + ingredientHandler *handler.IngredientHandler authMiddleware *middleware.AuthMiddleware } @@ -61,7 +63,9 @@ func NewRouter(cfg *config.Config, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, tableService *service.TableServiceImpl, - tableValidator *validator.TableValidator) *Router { + tableValidator *validator.TableValidator, + unitService handler.UnitService, + ingredientService handler.IngredientService) *Router { return &Router{ config: cfg, @@ -80,6 +84,8 @@ func NewRouter(cfg *config.Config, paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator), analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()), tableHandler: handler.NewTableHandler(tableService, tableValidator), + unitHandler: handler.NewUnitHandler(unitService), + ingredientHandler: handler.NewIngredientHandler(ingredientService), authMiddleware: authMiddleware, } } @@ -133,6 +139,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { adminUsers.DELETE("/:id", r.userHandler.DeleteUser) adminUsers.PUT("/:id/activate", r.userHandler.ActivateUser) adminUsers.PUT("/:id/deactivate", r.userHandler.DeactivateUser) + adminUsers.POST("/select-outlet", r.userHandler.UpdateUserOutlet) } users.PUT("/:id/password", r.userHandler.ChangePassword) @@ -160,6 +167,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { categories.DELETE("/:id", r.categoryHandler.DeleteCategory) } + units := protected.Group("/units") + units.Use(r.authMiddleware.RequireAdminOrManager()) + { + units.POST("", r.unitHandler.Create) + units.GET("", r.unitHandler.GetAll) + units.GET("/:id", r.unitHandler.GetByID) + units.PUT("/:id", r.unitHandler.Update) + units.DELETE("/:id", r.unitHandler.Delete) + } + products := protected.Group("/products") products.Use(r.authMiddleware.RequireAdminOrManager()) { @@ -258,6 +275,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { tables.POST("/:id/release", r.tableHandler.ReleaseTable) } + ingredients := protected.Group("/ingredients") + ingredients.Use(r.authMiddleware.RequireAdminOrManager()) + { + ingredients.POST("", r.ingredientHandler.Create) + ingredients.GET("", r.ingredientHandler.GetAll) + ingredients.GET("/:id", r.ingredientHandler.GetByID) + ingredients.PUT("/:id", r.ingredientHandler.Update) + ingredients.DELETE("/:id", r.ingredientHandler.Delete) + } + outlets := protected.Group("/outlets") outlets.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/ingredient_processor.go b/internal/service/ingredient_processor.go new file mode 100644 index 0000000..ee75e93 --- /dev/null +++ b/internal/service/ingredient_processor.go @@ -0,0 +1,16 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type IngredientProcessor interface { + CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) + UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) + DeleteIngredient(ctx context.Context, id uuid.UUID) error + GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) + ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) +} diff --git a/internal/service/ingredient_service.go b/internal/service/ingredient_service.go new file mode 100644 index 0000000..1add5b9 --- /dev/null +++ b/internal/service/ingredient_service.go @@ -0,0 +1,38 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type IngredientServiceImpl struct { + ingredientProcessor IngredientProcessor +} + +func NewIngredientService(ingredientProcessor IngredientProcessor) *IngredientServiceImpl { + return &IngredientServiceImpl{ + ingredientProcessor: ingredientProcessor, + } +} + +func (s *IngredientServiceImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { + return s.ingredientProcessor.CreateIngredient(ctx, req) +} + +func (s *IngredientServiceImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) { + return s.ingredientProcessor.UpdateIngredient(ctx, id, req) +} + +func (s *IngredientServiceImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error { + return s.ingredientProcessor.DeleteIngredient(ctx, id) +} + +func (s *IngredientServiceImpl) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { + return s.ingredientProcessor.GetIngredientByID(ctx, id) +} + +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) +} diff --git a/internal/service/inventory_movement_processor.go b/internal/service/inventory_movement_processor.go new file mode 100644 index 0000000..de1734a --- /dev/null +++ b/internal/service/inventory_movement_processor.go @@ -0,0 +1,14 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type InventoryMovementProcessor interface { + CreateInventoryMovement(ctx context.Context, req *models.CreateInventoryMovementRequest) (*models.InventoryMovementResponse, error) + GetInventoryMovementByID(ctx context.Context, id uuid.UUID) (*models.InventoryMovementResponse, error) + ListInventoryMovements(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.InventoryMovementResponse], error) +} diff --git a/internal/service/inventory_movement_service.go b/internal/service/inventory_movement_service.go new file mode 100644 index 0000000..9300a5a --- /dev/null +++ b/internal/service/inventory_movement_service.go @@ -0,0 +1,96 @@ +package service + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" + "context" + "time" + + "github.com/google/uuid" +) + +type InventoryMovementService interface { + CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error + CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error +} + +type InventoryMovementServiceImpl struct { + inventoryMovementRepo repository.InventoryMovementRepository + ingredientRepo *repository.IngredientRepository +} + +func NewInventoryMovementService(inventoryMovementRepo repository.InventoryMovementRepository, ingredientRepo *repository.IngredientRepository) InventoryMovementService { + return &InventoryMovementServiceImpl{ + inventoryMovementRepo: inventoryMovementRepo, + ingredientRepo: ingredientRepo, + } +} + +func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error { + ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID) + if err != nil { + return err + } + + previousQuantity := ingredient.Stock + newQuantity := previousQuantity + quantity + + movement := &entities.InventoryMovement{ + ID: uuid.New(), + OrganizationID: organizationID, + OutletID: outletID, + ItemID: ingredientID, + ItemType: "INGREDIENT", + MovementType: movementType, + Quantity: quantity, + PreviousQuantity: previousQuantity, + NewQuantity: newQuantity, + UnitCost: unitCost, + TotalCost: unitCost * quantity, + ReferenceType: referenceType, + ReferenceID: referenceID, + UserID: userID, + Reason: &reason, + CreatedAt: time.Now(), + } + + err = s.inventoryMovementRepo.Create(ctx, movement) + if err != nil { + return err + } + + err = s.ingredientRepo.UpdateStock(ctx, ingredientID, quantity, organizationID) + if err != nil { + return err + } + + return nil +} + +func (s *InventoryMovementServiceImpl) CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error { + movement := &entities.InventoryMovement{ + ID: uuid.New(), + OrganizationID: organizationID, + OutletID: outletID, + ItemID: productID, + ItemType: "PRODUCT", + MovementType: movementType, + Quantity: quantity, + PreviousQuantity: 0, // TODO This would be fetched from product inventory + NewQuantity: 0, // TODO This would be calculated + UnitCost: unitCost, + TotalCost: unitCost * quantity, + ReferenceType: referenceType, + ReferenceID: referenceID, + UserID: userID, + Reason: &reason, + CreatedAt: time.Now(), + } + + err := s.inventoryMovementRepo.Create(ctx, movement) + if err != nil { + return err + } + + return nil +} diff --git a/internal/service/payment_method_service.go b/internal/service/payment_method_service.go index 8df2366..d0ecd7d 100644 --- a/internal/service/payment_method_service.go +++ b/internal/service/payment_method_service.go @@ -31,15 +31,11 @@ func NewPaymentMethodService(paymentMethodProcessor processor.PaymentMethodProce } func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, contextInfo *appcontext.ContextInfo, req *contract.CreatePaymentMethodRequest) *contract.Response { - // Convert contract to model modelReq := mappers.CreatePaymentMethodContractToModel(req) - - // Set organization ID from context if not provided if modelReq.OrganizationID == uuid.Nil && contextInfo != nil { modelReq.OrganizationID = contextInfo.OrganizationID } - // Process request response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ @@ -47,7 +43,6 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont }) } - // Convert model to contract contractResponse := mappers.PaymentMethodResponseToContract(response) return contract.BuildSuccessResponse(contractResponse) } diff --git a/internal/service/unit_processor.go b/internal/service/unit_processor.go new file mode 100644 index 0000000..9915957 --- /dev/null +++ b/internal/service/unit_processor.go @@ -0,0 +1,16 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type UnitProcessor interface { + CreateUnit(ctx context.Context, req *models.CreateUnitRequest) (*models.UnitResponse, error) + UpdateUnit(ctx context.Context, id uuid.UUID, req *models.UpdateUnitRequest) (*models.UnitResponse, error) + DeleteUnit(ctx context.Context, id uuid.UUID) error + GetUnitByID(ctx context.Context, id uuid.UUID) (*models.UnitResponse, error) + ListUnits(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.UnitResponse], error) +} diff --git a/internal/service/unit_service.go b/internal/service/unit_service.go new file mode 100644 index 0000000..c766b0a --- /dev/null +++ b/internal/service/unit_service.go @@ -0,0 +1,38 @@ +package service + +import ( + "apskel-pos-be/internal/models" + "context" + + "github.com/google/uuid" +) + +type UnitServiceImpl struct { + unitProcessor UnitProcessor +} + +func NewUnitService(unitProcessor UnitProcessor) *UnitServiceImpl { + return &UnitServiceImpl{ + unitProcessor: unitProcessor, + } +} + +func (s *UnitServiceImpl) CreateUnit(ctx context.Context, req *models.CreateUnitRequest) (*models.UnitResponse, error) { + return s.unitProcessor.CreateUnit(ctx, req) +} + +func (s *UnitServiceImpl) UpdateUnit(ctx context.Context, id uuid.UUID, req *models.UpdateUnitRequest) (*models.UnitResponse, error) { + return s.unitProcessor.UpdateUnit(ctx, id, req) +} + +func (s *UnitServiceImpl) DeleteUnit(ctx context.Context, id uuid.UUID) error { + return s.unitProcessor.DeleteUnit(ctx, id) +} + +func (s *UnitServiceImpl) GetUnitByID(ctx context.Context, id uuid.UUID) (*models.UnitResponse, error) { + return s.unitProcessor.GetUnitByID(ctx, id) +} + +func (s *UnitServiceImpl) ListUnits(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.UnitResponse], error) { + return s.unitProcessor.ListUnits(ctx, organizationID, outletID, page, limit, search) +} diff --git a/internal/validator/payment_method_validator.go b/internal/validator/payment_method_validator.go index 86763bf..8173abf 100644 --- a/internal/validator/payment_method_validator.go +++ b/internal/validator/payment_method_validator.go @@ -28,7 +28,6 @@ func (v *PaymentMethodValidatorImpl) ValidateCreatePaymentMethodRequest(req *con return err, constants.ValidationErrorCode } - // Additional business logic validation if req.Name == "" { return constants.ErrPaymentMethodNameRequired, constants.MissingFieldErrorCode } diff --git a/migrations/000026_create_units_table.down.sql b/migrations/000026_create_units_table.down.sql new file mode 100644 index 0000000..12583b9 --- /dev/null +++ b/migrations/000026_create_units_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS units; \ No newline at end of file diff --git a/migrations/000026_create_units_table.up.sql b/migrations/000026_create_units_table.up.sql new file mode 100644 index 0000000..8d53b7b --- /dev/null +++ b/migrations/000026_create_units_table.up.sql @@ -0,0 +1,17 @@ +-- Units table +CREATE TABLE units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID REFERENCES outlets(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + abbreviation VARCHAR(10), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_units_organization_id ON units(organization_id); +CREATE INDEX idx_units_outlet_id ON units(outlet_id); +CREATE INDEX idx_units_is_active ON units(is_active); +CREATE INDEX idx_units_created_at ON units(created_at); \ No newline at end of file diff --git a/migrations/000027_create_ingredients_table.down.sql b/migrations/000027_create_ingredients_table.down.sql new file mode 100644 index 0000000..64d570a --- /dev/null +++ b/migrations/000027_create_ingredients_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ingredients; \ No newline at end of file diff --git a/migrations/000027_create_ingredients_table.up.sql b/migrations/000027_create_ingredients_table.up.sql new file mode 100644 index 0000000..7a6d915 --- /dev/null +++ b/migrations/000027_create_ingredients_table.up.sql @@ -0,0 +1,23 @@ +-- Ingredients table +CREATE TABLE ingredients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID REFERENCES outlets(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + unit_id UUID NOT NULL REFERENCES units(id) ON DELETE CASCADE, + cost DECIMAL(12,2) DEFAULT 0, + stock DECIMAL(12,3) DEFAULT 0, + is_semi_finished BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_ingredients_organization_id ON ingredients(organization_id); +CREATE INDEX idx_ingredients_outlet_id ON ingredients(outlet_id); +CREATE INDEX idx_ingredients_unit_id ON ingredients(unit_id); +CREATE INDEX idx_ingredients_is_semi_finished ON ingredients(is_semi_finished); +CREATE INDEX idx_ingredients_is_active ON ingredients(is_active); +CREATE INDEX idx_ingredients_created_at ON ingredients(created_at); \ No newline at end of file diff --git a/migrations/000028_create_ingredient_compositions_table.down.sql b/migrations/000028_create_ingredient_compositions_table.down.sql new file mode 100644 index 0000000..46a320f --- /dev/null +++ b/migrations/000028_create_ingredient_compositions_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ingredient_compositions; \ No newline at end of file diff --git a/migrations/000028_create_ingredient_compositions_table.up.sql b/migrations/000028_create_ingredient_compositions_table.up.sql new file mode 100644 index 0000000..30aaae2 --- /dev/null +++ b/migrations/000028_create_ingredient_compositions_table.up.sql @@ -0,0 +1,21 @@ +-- Ingredient compositions table +CREATE TABLE ingredient_compositions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID REFERENCES outlets(id) ON DELETE CASCADE, + parent_ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + child_ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + quantity DECIMAL(12,3) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_ingredient_compositions_organization_id ON ingredient_compositions(organization_id); +CREATE INDEX idx_ingredient_compositions_outlet_id ON ingredient_compositions(outlet_id); +CREATE INDEX idx_ingredient_compositions_parent_ingredient_id ON ingredient_compositions(parent_ingredient_id); +CREATE INDEX idx_ingredient_compositions_child_ingredient_id ON ingredient_compositions(child_ingredient_id); +CREATE INDEX idx_ingredient_compositions_created_at ON ingredient_compositions(created_at); + +-- Unique constraint to prevent duplicate compositions +CREATE UNIQUE INDEX idx_ingredient_compositions_unique ON ingredient_compositions(parent_ingredient_id, child_ingredient_id); \ No newline at end of file diff --git a/migrations/000029_create_product_ingredients_table.down.sql b/migrations/000029_create_product_ingredients_table.down.sql new file mode 100644 index 0000000..bf847aa --- /dev/null +++ b/migrations/000029_create_product_ingredients_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS product_ingredients; \ No newline at end of file diff --git a/migrations/000029_create_product_ingredients_table.up.sql b/migrations/000029_create_product_ingredients_table.up.sql new file mode 100644 index 0000000..fb69816 --- /dev/null +++ b/migrations/000029_create_product_ingredients_table.up.sql @@ -0,0 +1,21 @@ +-- Product ingredients table +CREATE TABLE product_ingredients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID REFERENCES outlets(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, + quantity DECIMAL(12,3) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_product_ingredients_organization_id ON product_ingredients(organization_id); +CREATE INDEX idx_product_ingredients_outlet_id ON product_ingredients(outlet_id); +CREATE INDEX idx_product_ingredients_product_id ON product_ingredients(product_id); +CREATE INDEX idx_product_ingredients_ingredient_id ON product_ingredients(ingredient_id); +CREATE INDEX idx_product_ingredients_created_at ON product_ingredients(created_at); + +-- Unique constraint to prevent duplicate product-ingredient combinations +CREATE UNIQUE INDEX idx_product_ingredients_unique ON product_ingredients(product_id, ingredient_id); \ No newline at end of file diff --git a/migrations/000030_add_unit_id_and_has_ingredients_to_products.down.sql b/migrations/000030_add_unit_id_and_has_ingredients_to_products.down.sql new file mode 100644 index 0000000..be9241e --- /dev/null +++ b/migrations/000030_add_unit_id_and_has_ingredients_to_products.down.sql @@ -0,0 +1,4 @@ +-- Remove unit_id and has_ingredients from products table +ALTER TABLE products +DROP COLUMN IF EXISTS unit_id, +DROP COLUMN IF EXISTS has_ingredients; \ No newline at end of file diff --git a/migrations/000030_add_unit_id_and_has_ingredients_to_products.up.sql b/migrations/000030_add_unit_id_and_has_ingredients_to_products.up.sql new file mode 100644 index 0000000..804c874 --- /dev/null +++ b/migrations/000030_add_unit_id_and_has_ingredients_to_products.up.sql @@ -0,0 +1,8 @@ +-- Add unit_id and has_ingredients to products table +ALTER TABLE products +ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE SET NULL, +ADD COLUMN has_ingredients BOOLEAN DEFAULT false; + +-- Index for unit_id +CREATE INDEX idx_products_unit_id ON products(unit_id); +CREATE INDEX idx_products_has_ingredients ON products(has_ingredients); \ No newline at end of file diff --git a/migrations/000031_update_inventory_movements_for_ingredients.down.sql b/migrations/000031_update_inventory_movements_for_ingredients.down.sql new file mode 100644 index 0000000..126afb5 --- /dev/null +++ b/migrations/000031_update_inventory_movements_for_ingredients.down.sql @@ -0,0 +1,30 @@ +-- Revert inventory_movements table changes +-- Add back product_id column +ALTER TABLE inventory_movements +ADD COLUMN product_id UUID; + +-- Copy item_id data back to product_id where item_type is 'PRODUCT' +UPDATE inventory_movements +SET product_id = item_id +WHERE item_type = 'PRODUCT'; + +-- Drop the new columns +ALTER TABLE inventory_movements +DROP COLUMN item_id, +DROP COLUMN item_type; + +-- Revert quantity columns to integer +ALTER TABLE inventory_movements +ALTER COLUMN quantity TYPE INTEGER USING quantity::integer, +ALTER COLUMN previous_quantity TYPE INTEGER USING previous_quantity::integer, +ALTER COLUMN new_quantity TYPE INTEGER USING new_quantity::integer; + +-- Revert cost columns to original precision +ALTER TABLE inventory_movements +ALTER COLUMN unit_cost TYPE DECIMAL(10,2), +ALTER COLUMN total_cost TYPE DECIMAL(10,2); + +-- Drop the new indexes +DROP INDEX IF EXISTS idx_inventory_movements_item_id; +DROP INDEX IF EXISTS idx_inventory_movements_item_type; +DROP INDEX IF EXISTS idx_inventory_movements_item_id_type; \ No newline at end of file diff --git a/migrations/000031_update_inventory_movements_for_ingredients.up.sql b/migrations/000031_update_inventory_movements_for_ingredients.up.sql new file mode 100644 index 0000000..bbf67c1 --- /dev/null +++ b/migrations/000031_update_inventory_movements_for_ingredients.up.sql @@ -0,0 +1,35 @@ +-- Update inventory_movements table to support ingredients +ALTER TABLE inventory_movements +ADD COLUMN item_id UUID, +ADD COLUMN item_type VARCHAR(20); + +-- Copy existing product_id data to item_id +UPDATE inventory_movements +SET item_id = product_id, + item_type = 'PRODUCT' +WHERE product_id IS NOT NULL; + +-- Make item_id and item_type NOT NULL after data migration +ALTER TABLE inventory_movements +ALTER COLUMN item_id SET NOT NULL, +ALTER COLUMN item_type SET NOT NULL; + +-- Drop the old product_id column +ALTER TABLE inventory_movements +DROP COLUMN product_id; + +-- Update quantity columns to support decimal +ALTER TABLE inventory_movements +ALTER COLUMN quantity TYPE DECIMAL(12,3), +ALTER COLUMN previous_quantity TYPE DECIMAL(12,3), +ALTER COLUMN new_quantity TYPE DECIMAL(12,3); + +-- Update cost columns to support higher precision +ALTER TABLE inventory_movements +ALTER COLUMN unit_cost TYPE DECIMAL(12,2), +ALTER COLUMN total_cost TYPE DECIMAL(12,2); + +-- Add indexes for the new structure +CREATE INDEX idx_inventory_movements_item_id ON inventory_movements(item_id); +CREATE INDEX idx_inventory_movements_item_type ON inventory_movements(item_type); +CREATE INDEX idx_inventory_movements_item_id_type ON inventory_movements(item_id, item_type); \ No newline at end of file diff --git a/migrations/000032_fix_user_permissions.down.sql b/migrations/000032_fix_user_permissions.down.sql new file mode 100644 index 0000000..647f32c --- /dev/null +++ b/migrations/000032_fix_user_permissions.down.sql @@ -0,0 +1,2 @@ +-- Revert the permissions fix +ALTER TABLE users ALTER COLUMN permissions SET DEFAULT '{}'::jsonb; \ No newline at end of file diff --git a/migrations/000032_fix_user_permissions.up.sql b/migrations/000032_fix_user_permissions.up.sql new file mode 100644 index 0000000..99dc3f3 --- /dev/null +++ b/migrations/000032_fix_user_permissions.up.sql @@ -0,0 +1,7 @@ +-- Fix any existing users with invalid permissions data +UPDATE users +SET permissions = '{}'::jsonb +WHERE permissions IS NULL OR permissions = 'null'::jsonb OR permissions = '[]'::jsonb; + +-- Ensure all users have valid permissions +ALTER TABLE users ALTER COLUMN permissions SET DEFAULT '{}'::jsonb; \ No newline at end of file