diff --git a/internal/app/app.go b/internal/app/app.go index 5058603..9493673 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -40,7 +40,7 @@ func NewApp(db *gorm.DB) *App { func (a *App) Initialize(cfg *config.Config) error { repos := a.initRepositories() processors := a.initProcessors(cfg, repos) - services := a.initServices(processors, cfg) + services := a.initServices(processors, repos, cfg) validators := a.initValidators() middleware := a.initMiddleware(services) healthHandler := handler.NewHealthHandler() @@ -140,7 +140,7 @@ type repositories struct { fileRepo *repository.FileRepositoryImpl customerRepo *repository.CustomerRepository analyticsRepo *repository.AnalyticsRepositoryImpl - tableRepo *repository.TableRepository + tableRepo repository.TableRepositoryInterface unitRepo *repository.UnitRepository ingredientRepo *repository.IngredientRepository } @@ -232,7 +232,7 @@ type services struct { ingredientService *service.IngredientServiceImpl } -func (a *App) initServices(processors *processors, cfg *config.Config) *services { +func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { authConfig := cfg.Auth() jwtSecret := authConfig.AccessTokenSecret() authService := service.NewAuthService(processors.userProcessor, jwtSecret) @@ -243,7 +243,7 @@ func (a *App) initServices(processors *processors, cfg *config.Config) *services productService := service.NewProductService(processors.productProcessor) productVariantService := service.NewProductVariantService(processors.productVariantProcessor) inventoryService := service.NewInventoryService(processors.inventoryProcessor) - orderService := service.NewOrderServiceImpl(processors.orderProcessor) + orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo) paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor) fileService := service.NewFileServiceImpl(processors.fileProcessor) var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor) diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index cc4e922..b2e0d93 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -10,6 +10,7 @@ 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"` + TableID *uuid.UUID `json:"table_id,omitempty" validate:"omitempty"` 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/entities/ingredient.go b/internal/entities/ingredient.go index 9fa3b9f..8a57d93 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -7,17 +7,17 @@ import ( ) 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"` + 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 Metadata `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/unit.go b/internal/entities/unit.go index e3bf997..ef04588 100644 --- a/internal/entities/unit.go +++ b/internal/entities/unit.go @@ -13,6 +13,11 @@ type Unit struct { Name string `gorm:"not null;size:255" json:"name"` Abbreviation *string `gorm:"size:50" json:"abbreviation"` IsActive bool `gorm:"default:true" json:"is_active"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } + +func (Unit) TableName() string { + return "units" +} diff --git a/internal/mappers/unit_mapper.go b/internal/mappers/unit_mapper.go index d07d205..a98af08 100644 --- a/internal/mappers/unit_mapper.go +++ b/internal/mappers/unit_mapper.go @@ -17,6 +17,7 @@ func MapUnitEntityToModel(entity *entities.Unit) *models.Unit { Name: entity.Name, Abbreviation: entity.Abbreviation, IsActive: entity.IsActive, + DeletedAt: entity.DeletedAt, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, } @@ -34,6 +35,7 @@ func MapUnitModelToEntity(model *models.Unit) *entities.Unit { Name: model.Name, Abbreviation: model.Abbreviation, IsActive: model.IsActive, + DeletedAt: model.DeletedAt, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 6a75c62..232cb9c 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -1,65 +1,66 @@ package models import ( + "apskel-pos-be/internal/entities" "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"` + 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 entities.Metadata `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"` + 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 entities.Metadata `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"` + 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 entities.Metadata `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"` + 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 entities.Metadata `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/order.go b/internal/models/order.go index e646392..beb8648 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -85,6 +85,7 @@ type CreateOrderRequest struct { OutletID uuid.UUID `validate:"required"` UserID uuid.UUID `validate:"required"` CustomerID *uuid.UUID `validate:"omitempty"` + TableID *uuid.UUID `validate:"omitempty"` TableNumber *string `validate:"omitempty,max=50"` OrderType constants.OrderType `validate:"required"` OrderItems []CreateOrderItemRequest `validate:"required,min=1,dive"` diff --git a/internal/models/unit.go b/internal/models/unit.go index 4dea9e9..7b7fb23 100644 --- a/internal/models/unit.go +++ b/internal/models/unit.go @@ -13,6 +13,7 @@ type Unit struct { Name string `json:"name"` Abbreviation *string `json:"abbreviation"` IsActive bool `json:"is_active"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -39,6 +40,7 @@ type UnitResponse struct { Name string `json:"name"` Abbreviation *string `json:"abbreviation"` IsActive bool `json:"is_active"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` 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 index 78aa9bb..4f6b05d 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -43,19 +43,16 @@ func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *mod 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, diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 2b74f51..5625e31 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -82,10 +82,6 @@ type PaymentMethodRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentMethod, error) } -type ProductVariantRepository interface { - GetByID(ctx context.Context, id uuid.UUID) (*entities.ProductVariant, error) -} - type CustomerRepository interface { GetByIDAndOrganization(ctx context.Context, id, organizationID uuid.UUID) (*entities.Customer, error) } @@ -111,7 +107,7 @@ type OrderProcessorImpl struct { paymentMethodRepo PaymentMethodRepository inventoryRepo repository.InventoryRepository inventoryMovementRepo repository.InventoryMovementRepository - productVariantRepo ProductVariantRepository + productVariantRepo repository.ProductVariantRepository outletRepo OutletRepository customerRepo CustomerRepository } @@ -125,7 +121,7 @@ func NewOrderProcessorImpl( paymentMethodRepo PaymentMethodRepository, inventoryRepo repository.InventoryRepository, inventoryMovementRepo repository.InventoryMovementRepository, - productVariantRepo ProductVariantRepository, + productVariantRepo repository.ProductVariantRepository, outletRepo OutletRepository, customerRepo CustomerRepository, ) *OrderProcessorImpl { @@ -193,7 +189,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, Quantity: itemReq.Quantity, - UnitPrice: unitPrice, // Use price from database + UnitPrice: unitPrice, TotalPrice: itemTotalPrice, UnitCost: unitCost, TotalCost: itemTotalCost, diff --git a/internal/processor/table_processor.go b/internal/processor/table_processor.go index 7e2bb39..5cc872e 100644 --- a/internal/processor/table_processor.go +++ b/internal/processor/table_processor.go @@ -13,11 +13,11 @@ import ( ) type TableProcessor struct { - tableRepo *repository.TableRepository + tableRepo repository.TableRepositoryInterface orderRepo repository.OrderRepository } -func NewTableProcessor(tableRepo *repository.TableRepository, orderRepo repository.OrderRepository) *TableProcessor { +func NewTableProcessor(tableRepo repository.TableRepositoryInterface, orderRepo repository.OrderRepository) *TableProcessor { return &TableProcessor{ tableRepo: tableRepo, orderRepo: orderRepo, diff --git a/internal/processor/unit_processor.go b/internal/processor/unit_processor.go index 73dcc2d..e8cd268 100644 --- a/internal/processor/unit_processor.go +++ b/internal/processor/unit_processor.go @@ -45,6 +45,7 @@ func (p *UnitProcessorImpl) CreateUnit(ctx context.Context, req *models.CreateUn Name: unitModel.Name, Abbreviation: unitModel.Abbreviation, IsActive: unitModel.IsActive, + DeletedAt: unitModel.DeletedAt, CreatedAt: unitModel.CreatedAt, UpdatedAt: unitModel.UpdatedAt, } @@ -68,6 +69,7 @@ func (p *UnitProcessorImpl) GetUnitByID(ctx context.Context, id uuid.UUID) (*mod Name: unitModel.Name, Abbreviation: unitModel.Abbreviation, IsActive: unitModel.IsActive, + DeletedAt: unitModel.DeletedAt, CreatedAt: unitModel.CreatedAt, UpdatedAt: unitModel.UpdatedAt, } @@ -102,6 +104,7 @@ func (p *UnitProcessorImpl) ListUnits(ctx context.Context, organizationID uuid.U Name: unitModel.Name, Abbreviation: unitModel.Abbreviation, IsActive: unitModel.IsActive, + DeletedAt: unitModel.DeletedAt, CreatedAt: unitModel.CreatedAt, UpdatedAt: unitModel.UpdatedAt, } @@ -147,6 +150,7 @@ func (p *UnitProcessorImpl) UpdateUnit(ctx context.Context, id uuid.UUID, req *m Name: unitModel.Name, Abbreviation: unitModel.Abbreviation, IsActive: unitModel.IsActive, + DeletedAt: unitModel.DeletedAt, CreatedAt: unitModel.CreatedAt, UpdatedAt: unitModel.UpdatedAt, } diff --git a/internal/processor/unit_processor_test.go b/internal/processor/unit_processor_test.go deleted file mode 100644 index 2695327..0000000 --- a/internal/processor/unit_processor_test.go +++ /dev/null @@ -1,153 +0,0 @@ -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/repository/table_repository_interface.go b/internal/repository/table_repository_interface.go new file mode 100644 index 0000000..a405482 --- /dev/null +++ b/internal/repository/table_repository_interface.go @@ -0,0 +1,26 @@ +package repository + +import ( + "context" + "time" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" +) + +// TableRepositoryInterface defines the interface for table repository operations +type TableRepositoryInterface interface { + Create(ctx context.Context, table *entities.Table) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.Table, error) + GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) + GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]entities.Table, error) + Update(ctx context.Context, table *entities.Table) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, organizationID, outletID *uuid.UUID, status *string, isActive *bool, search string, page, limit int) ([]entities.Table, int64, error) + GetAvailableTables(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) + GetOccupiedTables(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) + OccupyTable(ctx context.Context, tableID, orderID uuid.UUID, startTime *time.Time) error + ReleaseTable(ctx context.Context, tableID uuid.UUID, paymentAmount float64) error + GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) +} diff --git a/internal/repository/unit_repository.go b/internal/repository/unit_repository.go index 02144be..305079a 100644 --- a/internal/repository/unit_repository.go +++ b/internal/repository/unit_repository.go @@ -4,6 +4,7 @@ import ( "apskel-pos-be/internal/entities" "context" "fmt" + "time" "gorm.io/gorm" @@ -24,7 +25,7 @@ func (r *UnitRepository) Create(ctx context.Context, unit *entities.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 + err := r.db.WithContext(ctx).Where("id = ? AND organization_id = ? AND deleted_at IS NULL", id, organizationID).First(&unit).Error if err != nil { return nil, err } @@ -35,7 +36,7 @@ func (r *UnitRepository) GetAll(ctx context.Context, organizationID uuid.UUID, o var units []*entities.Unit var total int64 - query := r.db.WithContext(ctx).Model(&entities.Unit{}).Where("organization_id = ?", organizationID) + query := r.db.WithContext(ctx).Model(&entities.Unit{}).Where("organization_id = ? AND deleted_at IS NULL", organizationID) if outletID != nil { query = query.Where("outlet_id = ?", *outletID) @@ -62,7 +63,7 @@ func (r *UnitRepository) GetAll(ctx context.Context, organizationID uuid.UUID, o } 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) + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ? AND deleted_at IS NULL", unit.ID, unit.OrganizationID).Save(unit) if result.Error != nil { return result.Error } @@ -73,7 +74,11 @@ func (r *UnitRepository) Update(ctx context.Context, unit *entities.Unit) error } 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{}) + now := time.Now() + result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ? AND deleted_at IS NULL", id, organizationID). + Updates(map[string]interface{}{ + "deleted_at": &now, + }) if result.Error != nil { return result.Error } diff --git a/internal/service/order_service.go b/internal/service/order_service.go index a09254b..a4da84b 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -3,9 +3,11 @@ package service import ( "context" "fmt" + "time" "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" "github.com/google/uuid" ) @@ -26,11 +28,13 @@ type OrderService interface { type OrderServiceImpl struct { orderProcessor processor.OrderProcessor + tableRepo repository.TableRepositoryInterface } -func NewOrderServiceImpl(orderProcessor processor.OrderProcessor) *OrderServiceImpl { +func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface) *OrderServiceImpl { return &OrderServiceImpl{ orderProcessor: orderProcessor, + tableRepo: tableRepo, } } @@ -39,11 +43,23 @@ func (s *OrderServiceImpl) CreateOrder(ctx context.Context, req *models.CreateOr return nil, fmt.Errorf("validation error: %w", err) } + if req.TableID != nil { + if err := s.validateTable(ctx, req); err != nil { + return nil, fmt.Errorf("table validation failed: %w", err) + } + } + response, err := s.orderProcessor.CreateOrder(ctx, req, organizationID) if err != nil { return nil, fmt.Errorf("failed to create order: %w", err) } + if req.TableID != nil { + if err := s.occupyTableWithOrder(ctx, *req.TableID, response.ID); err != nil { + fmt.Printf("Warning: failed to occupy table %s with order %s: %v\n", *req.TableID, response.ID, err) + } + } + return response, nil } @@ -121,6 +137,12 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR return fmt.Errorf("failed to void order: %w", err) } + // Release table if order is voided + if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil { + // Log the error but don't fail the void operation + fmt.Printf("Warning: failed to handle table release for voided order %s: %v\n", req.OrderID, err) + } + return nil } @@ -151,12 +173,15 @@ func (s *OrderServiceImpl) CreatePayment(ctx context.Context, req *models.Create return nil, fmt.Errorf("validation error: %w", err) } - // Process payment creation response, err := s.orderProcessor.CreatePayment(ctx, req) if err != nil { return nil, fmt.Errorf("failed to create payment: %w", err) } + if err := s.handleTableReleaseOnPayment(ctx, req.OrderID); err != nil { + fmt.Printf("Warning: failed to handle table release for order %s: %v\n", req.OrderID, err) + } + return response, nil } @@ -247,6 +272,11 @@ func (s *OrderServiceImpl) validateCreateOrderRequest(req *models.CreateOrderReq return fmt.Errorf("user ID is required") } + // Validate table ID if provided + if req.TableID != nil && *req.TableID == uuid.Nil { + return fmt.Errorf("table ID cannot be nil if provided") + } + if len(req.OrderItems) == 0 { return fmt.Errorf("order must have at least one item") } @@ -445,3 +475,71 @@ func (s *OrderServiceImpl) validateSplitBillRequest(req *models.SplitBillRequest return nil } + +// validateTable validates that the table exists and is available for occupation +func (s *OrderServiceImpl) validateTable(ctx context.Context, req *models.CreateOrderRequest) error { + // Validate table exists and is available + table, err := s.tableRepo.GetByID(ctx, *req.TableID) + if err != nil { + return fmt.Errorf("table not found: %w", err) + } + + // Check if table belongs to the same outlet + if table.OutletID != req.OutletID { + return fmt.Errorf("table does not belong to the specified outlet") + } + + // Check if table is available for occupation + if !table.CanBeOccupied() { + return fmt.Errorf("table is not available for occupation (current status: %s)", table.Status) + } + + return nil +} + +func (s *OrderServiceImpl) occupyTableWithOrder(ctx context.Context, tableID, orderID uuid.UUID) error { + startTime := time.Now() + if err := s.tableRepo.OccupyTable(ctx, tableID, orderID, &startTime); err != nil { + return fmt.Errorf("failed to occupy table: %w", err) + } + return nil +} + +func (s *OrderServiceImpl) handleTableReleaseOnPayment(ctx context.Context, orderID uuid.UUID) error { + order, err := s.orderProcessor.GetOrderByID(ctx, orderID) + if err != nil { + return fmt.Errorf("failed to get order: %w", err) + } + + if order.PaymentStatus == "completed" { + table, err := s.tableRepo.GetByOrderID(ctx, orderID) + if err != nil { + return nil + } + + if table != nil { + if err := s.tableRepo.ReleaseTable(ctx, table.ID, order.TotalAmount); err != nil { + return fmt.Errorf("failed to release table: %w", err) + } + } + } + + return nil +} + +// handleTableReleaseOnVoid releases the table when an order is voided +func (s *OrderServiceImpl) handleTableReleaseOnVoid(ctx context.Context, orderID uuid.UUID) error { + table, err := s.tableRepo.GetByOrderID(ctx, orderID) + if err != nil { + // Table might not exist or not be occupied, which is fine + return nil + } + + if table != nil { + if err := s.tableRepo.ReleaseTable(ctx, table.ID, 0); err != nil { + return fmt.Errorf("failed to release table: %w", err) + } + } + + return nil +} diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go new file mode 100644 index 0000000..e961712 --- /dev/null +++ b/internal/service/order_service_table_test.go @@ -0,0 +1,308 @@ +package service + +import ( + "context" + "testing" + "time" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Mock implementations for testing +type MockOrderProcessor struct { + mock.Mock +} + +func (m *MockOrderProcessor) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) { + args := m.Called(ctx, req, organizationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.OrderResponse), args.Error(1) +} + +func (m *MockOrderProcessor) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) { + args := m.Called(ctx, orderID, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.AddToOrderResponse), args.Error(1) +} + +func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) { + args := m.Called(ctx, id, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.OrderResponse), args.Error(1) +} + +func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.OrderResponse), args.Error(1) +} + +func (m *MockOrderProcessor) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.ListOrdersResponse), args.Error(1) +} + +func (m *MockOrderProcessor) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error { + args := m.Called(ctx, req, voidedBy) + return args.Error(0) +} + +func (m *MockOrderProcessor) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error { + args := m.Called(ctx, id, req, refundedBy) + return args.Error(0) +} + +func (m *MockOrderProcessor) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.PaymentResponse), args.Error(1) +} + +func (m *MockOrderProcessor) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error { + args := m.Called(ctx, paymentID, refundAmount, reason, refundedBy) + return args.Error(0) +} + +func (m *MockOrderProcessor) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) { + args := m.Called(ctx, orderID, req, organizationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.SetOrderCustomerResponse), args.Error(1) +} + +func (m *MockOrderProcessor) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.SplitBillResponse), args.Error(1) +} + +type MockTableRepository struct { + mock.Mock +} + +func (m *MockTableRepository) Create(ctx context.Context, table *entities.Table) error { + args := m.Called(ctx, table) + return args.Error(0) +} + +func (m *MockTableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Table, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entities.Table), args.Error(1) +} + +func (m *MockTableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) { + args := m.Called(ctx, outletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]entities.Table), args.Error(1) +} + +func (m *MockTableRepository) GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]entities.Table, error) { + args := m.Called(ctx, organizationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]entities.Table), args.Error(1) +} + +func (m *MockTableRepository) Update(ctx context.Context, table *entities.Table) error { + args := m.Called(ctx, table) + return args.Error(0) +} + +func (m *MockTableRepository) Delete(ctx context.Context, id uuid.UUID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockTableRepository) List(ctx context.Context, organizationID, outletID *uuid.UUID, status *string, isActive *bool, search string, page, limit int) ([]entities.Table, int64, error) { + args := m.Called(ctx, organizationID, outletID, status, isActive, search, page, limit) + if args.Get(0) == nil { + return nil, 0, args.Error(2) + } + return args.Get(0).([]entities.Table), args.Get(1).(int64), args.Error(2) +} + +func (m *MockTableRepository) GetAvailableTables(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) { + args := m.Called(ctx, outletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]entities.Table), args.Error(1) +} + +func (m *MockTableRepository) GetOccupiedTables(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) { + args := m.Called(ctx, outletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]entities.Table), args.Error(1) +} + +func (m *MockTableRepository) OccupyTable(ctx context.Context, tableID, orderID uuid.UUID, startTime *time.Time) error { + args := m.Called(ctx, tableID, orderID, startTime) + return args.Error(0) +} + +func (m *MockTableRepository) ReleaseTable(ctx context.Context, tableID uuid.UUID, paymentAmount float64) error { + args := m.Called(ctx, tableID, paymentAmount) + return args.Error(0) +} + +func (m *MockTableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) { + args := m.Called(ctx, orderID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entities.Table), args.Error(1) +} + +func TestCreateOrderWithTableOccupation(t *testing.T) { + // Setup + ctx := context.Background() + organizationID := uuid.New() + outletID := uuid.New() + userID := uuid.New() + tableID := uuid.New() + orderID := uuid.New() + + // Create mock table + mockTable := &entities.Table{ + ID: tableID, + OrganizationID: organizationID, + OutletID: outletID, + TableName: "Table 1", + Status: "available", + IsActive: true, + } + + // Create mock order response + mockOrderResponse := &models.OrderResponse{ + ID: orderID, + // Add other required fields + } + + // Create mock repositories + mockTableRepo := &MockTableRepository{} + mockOrderProcessor := &MockOrderProcessor{} + + // Set up expectations + mockTableRepo.On("GetByID", ctx, tableID).Return(mockTable, nil) + mockOrderProcessor.On("CreateOrder", ctx, mock.AnythingOfType("*models.CreateOrderRequest"), organizationID).Return(mockOrderResponse, nil) + mockTableRepo.On("OccupyTable", ctx, tableID, orderID, mock.AnythingOfType("*time.Time")).Return(nil) + + // Create service with mock dependencies + service := &OrderServiceImpl{ + orderProcessor: mockOrderProcessor, + tableRepo: mockTableRepo, + } + + // Create test request + req := &models.CreateOrderRequest{ + OutletID: outletID, + UserID: userID, + TableID: &tableID, + OrderType: "dine_in", + OrderItems: []models.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + }, + }, + } + + // Test order creation with table occupation + response, err := service.CreateOrder(ctx, req, organizationID) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, orderID, response.ID) + + // Verify mock calls + mockTableRepo.AssertExpectations(t) + mockOrderProcessor.AssertExpectations(t) +} + +func TestCreateOrderTableValidationFailure(t *testing.T) { + // Setup + ctx := context.Background() + organizationID := uuid.New() + outletID := uuid.New() + userID := uuid.New() + tableID := uuid.New() + + // Create mock table that's already occupied + mockTable := &entities.Table{ + ID: tableID, + OrganizationID: organizationID, + OutletID: outletID, + TableName: "Table 1", + Status: "occupied", // Already occupied + IsActive: true, + } + + // Create mock repositories + mockTableRepo := &MockTableRepository{} + mockOrderProcessor := &MockOrderProcessor{} + + // Set up expectations + mockTableRepo.On("GetByID", ctx, tableID).Return(mockTable, nil) + + // Create service with mock dependencies + service := &OrderServiceImpl{ + orderProcessor: mockOrderProcessor, + tableRepo: mockTableRepo, + } + + // Create test request + req := &models.CreateOrderRequest{ + OutletID: outletID, + UserID: userID, + TableID: &tableID, + OrderType: "dine_in", + OrderItems: []models.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + }, + }, + } + + // Test order creation with occupied table + response, err := service.CreateOrder(ctx, req, organizationID) + + // Assertions + assert.Error(t, err) + assert.Nil(t, response) + assert.Contains(t, err.Error(), "table is not available for occupation") + + // Verify mock calls + mockTableRepo.AssertExpectations(t) + mockOrderProcessor.AssertNotCalled(t, "CreateOrder") +} diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 73cbe4f..7b407cf 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -26,6 +26,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create return &models.CreateOrderRequest{ OutletID: req.OutletID, UserID: req.UserID, + TableID: req.TableID, TableNumber: req.TableNumber, OrderType: constants.OrderType(req.OrderType), OrderItems: items, diff --git a/migrations/000035_update_payment_status_constraints.down.sql b/migrations/000035_update_payment_status_constraints.down.sql new file mode 100644 index 0000000..03805df --- /dev/null +++ b/migrations/000035_update_payment_status_constraints.down.sql @@ -0,0 +1,3 @@ +-- Revert payment status constraints to previous state +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_payment_status_check; +ALTER TABLE orders ADD CONSTRAINT orders_payment_status_check CHECK (payment_status IN ('pending', 'completed', 'failed', 'refunded', 'partially_refunded')); \ No newline at end of file diff --git a/migrations/000035_update_payment_status_constraints.up.sql b/migrations/000035_update_payment_status_constraints.up.sql new file mode 100644 index 0000000..b291cf4 --- /dev/null +++ b/migrations/000035_update_payment_status_constraints.up.sql @@ -0,0 +1,3 @@ +-- Update payment status constraints to include all defined statuses +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_payment_status_check; +ALTER TABLE orders ADD CONSTRAINT orders_payment_status_check CHECK (payment_status IN ('pending', 'partial', 'completed', 'failed', 'refunded', 'partial-refunded')); \ No newline at end of file diff --git a/migrations/000036_add_deleted_at_to_units.down.sql b/migrations/000036_add_deleted_at_to_units.down.sql new file mode 100644 index 0000000..16e19e5 --- /dev/null +++ b/migrations/000036_add_deleted_at_to_units.down.sql @@ -0,0 +1,3 @@ +-- Remove deleted_at column from units table +DROP INDEX IF EXISTS idx_units_deleted_at; +ALTER TABLE units DROP COLUMN IF EXISTS deleted_at; \ No newline at end of file diff --git a/migrations/000036_add_deleted_at_to_units.up.sql b/migrations/000036_add_deleted_at_to_units.up.sql new file mode 100644 index 0000000..44f84f5 --- /dev/null +++ b/migrations/000036_add_deleted_at_to_units.up.sql @@ -0,0 +1,3 @@ +-- Add deleted_at column to units table for soft delete functionality +ALTER TABLE units ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; +CREATE INDEX idx_units_deleted_at ON units(deleted_at); \ No newline at end of file