From 69d8c8ce5ecfe00041694165c79b4924d3b2aa70 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 12:29:59 +0700 Subject: [PATCH] Add category table --- internal/app/app.go | 11 + internal/constants/error.go | 1 + .../contract/purchase_category_contract.go | 57 +++++ internal/entities/purchase_category.go | 71 ++++++ internal/handler/purchase_category_handler.go | 160 +++++++++++++ internal/mappers/purchase_category_mapper.go | 53 +++++ internal/models/purchase_category.go | 51 +++++ .../processor/purchase_category_processor.go | 210 ++++++++++++++++++ .../purchase_category_repository.go | 91 ++++++++ internal/router/router.go | 14 +- internal/service/purchase_category_service.go | 107 +++++++++ .../purchase_category_transformer.go | 72 ++++++ .../validator/purchase_category_validator.go | 103 +++++++++ ...000075_create_purchase_categories.down.sql | 2 + .../000075_create_purchase_categories.up.sql | 64 ++++++ ...fault_purchase_categories_trigger.down.sql | 2 + ...default_purchase_categories_trigger.up.sql | 49 ++++ ...kfill_default_purchase_categories.down.sql | 3 + ...ackfill_default_purchase_categories.up.sql | 39 ++++ 19 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 internal/contract/purchase_category_contract.go create mode 100644 internal/entities/purchase_category.go create mode 100644 internal/handler/purchase_category_handler.go create mode 100644 internal/mappers/purchase_category_mapper.go create mode 100644 internal/models/purchase_category.go create mode 100644 internal/processor/purchase_category_processor.go create mode 100644 internal/repository/purchase_category_repository.go create mode 100644 internal/service/purchase_category_service.go create mode 100644 internal/transformer/purchase_category_transformer.go create mode 100644 internal/validator/purchase_category_validator.go create mode 100644 migrations/000075_create_purchase_categories.down.sql create mode 100644 migrations/000075_create_purchase_categories.up.sql create mode 100644 migrations/000076_add_default_purchase_categories_trigger.down.sql create mode 100644 migrations/000076_add_default_purchase_categories_trigger.up.sql create mode 100644 migrations/000077_backfill_default_purchase_categories.down.sql create mode 100644 migrations/000077_backfill_default_purchase_categories.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 82f4442..9e36acd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -107,6 +107,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.vendorValidator, services.purchaseOrderService, validators.purchaseOrderValidator, + services.purchaseCategoryService, + validators.purchaseCategoryValidator, services.unitConverterService, validators.unitConverterValidator, services.chartOfAccountTypeService, @@ -214,6 +216,7 @@ type repositories struct { productRecipeRepo *repository.ProductRecipeRepository vendorRepo *repository.VendorRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl + purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl @@ -267,6 +270,7 @@ func (a *App) initRepositories() *repositories { productRecipeRepo: repository.NewProductRecipeRepository(a.db), vendorRepo: repository.NewVendorRepositoryImpl(a.db), purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db), + purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db), unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl), chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db), chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), @@ -315,6 +319,7 @@ type processors struct { productRecipeProcessor *processor.ProductRecipeProcessorImpl vendorProcessor *processor.VendorProcessorImpl purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl + purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl @@ -366,6 +371,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), @@ -414,6 +420,7 @@ type services struct { productRecipeService *service.ProductRecipeServiceImpl vendorService *service.VendorServiceImpl purchaseOrderService *service.PurchaseOrderServiceImpl + purchaseCategoryService service.PurchaseCategoryService unitConverterService *service.IngredientUnitConverterServiceImpl chartOfAccountTypeService service.ChartOfAccountTypeService chartOfAccountService service.ChartOfAccountService @@ -453,6 +460,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor) vendorService := service.NewVendorService(processors.vendorProcessor) purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor) + purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor) unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor) chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor) chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) @@ -492,6 +500,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productRecipeService: productRecipeService, vendorService: vendorService, purchaseOrderService: purchaseOrderService, + purchaseCategoryService: purchaseCategoryService, unitConverterService: unitConverterService, chartOfAccountTypeService: chartOfAccountTypeService, chartOfAccountService: chartOfAccountService, @@ -537,6 +546,7 @@ type validators struct { tableValidator *validator.TableValidator vendorValidator *validator.VendorValidatorImpl purchaseOrderValidator *validator.PurchaseOrderValidatorImpl + purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl unitConverterValidator *validator.IngredientUnitConverterValidatorImpl chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl chartOfAccountValidator *validator.ChartOfAccountValidatorImpl @@ -568,6 +578,7 @@ func (a *App) initValidators() *validators { tableValidator: validator.NewTableValidator(), vendorValidator: validator.NewVendorValidator(), purchaseOrderValidator: validator.NewPurchaseOrderValidator(), + purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(), unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl), chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl), chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl), diff --git a/internal/constants/error.go b/internal/constants/error.go index 8937a58..af56665 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -40,6 +40,7 @@ const ( OutletServiceEntity = "outlet_service" VendorServiceEntity = "vendor_service" PurchaseOrderServiceEntity = "purchase_order_service" + PurchaseCategoryServiceEntity = "purchase_category_service" IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service" IngredientCompositionServiceEntity = "ingredient_composition_service" TableEntity = "table" diff --git a/internal/contract/purchase_category_contract.go b/internal/contract/purchase_category_contract.go new file mode 100644 index 0000000..8aed0ef --- /dev/null +++ b/internal/contract/purchase_category_contract.go @@ -0,0 +1,57 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreatePurchaseCategoryRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Code *string `json:"code,omitempty"` + Name string `json:"name" validate:"required,min=1,max=255"` + Type string `json:"type" validate:"required,oneof=raw_material non_inventory"` + SortOrder *int `json:"sort_order,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type UpdatePurchaseCategoryRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Code *string `json:"code,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + SortOrder *int `json:"sort_order,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type ListPurchaseCategoriesRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + Search string `json:"search,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + Page int `json:"page" validate:"required,min=1"` + Limit int `json:"limit" validate:"required,min=1,max=100"` +} + +type PurchaseCategoryResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + PresetID *uuid.UUID `json:"preset_id"` + ParentID *uuid.UUID `json:"parent_id"` + Code string `json:"code"` + Name string `json:"name"` + Type string `json:"type"` + SortOrder int `json:"sort_order"` + IsSystem bool `json:"is_system"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListPurchaseCategoriesResponse struct { + PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/entities/purchase_category.go b/internal/entities/purchase_category.go new file mode 100644 index 0000000..34c5667 --- /dev/null +++ b/internal/entities/purchase_category.go @@ -0,0 +1,71 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PurchaseCategoryType string + +const ( + PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" + PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" +) + +type PurchaseCategoryPreset struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` + Code string `gorm:"not null;unique;size:100" json:"code"` + Name string `gorm:"not null;size:255" json:"name"` + Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"` +} + +func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} + +func (PurchaseCategoryPreset) TableName() string { + return "purchase_category_presets" +} + +type PurchaseCategory 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"` + PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"` + ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` + Code string `gorm:"not null;size:100" json:"code"` + Name string `gorm:"not null;size:255" json:"name"` + Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + IsSystem bool `gorm:"not null;default:false" json:"is_system"` + IsActive bool `gorm:"not null;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"` + Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"` + Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (PurchaseCategory) TableName() string { + return "purchase_categories" +} diff --git a/internal/handler/purchase_category_handler.go b/internal/handler/purchase_category_handler.go new file mode 100644 index 0000000..1555004 --- /dev/null +++ b/internal/handler/purchase_category_handler.go @@ -0,0 +1,160 @@ +package handler + +import ( + "strconv" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type PurchaseCategoryHandler struct { + purchaseCategoryService service.PurchaseCategoryService + purchaseCategoryValidator validator.PurchaseCategoryValidator +} + +func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler { + return &PurchaseCategoryHandler{ + purchaseCategoryService: purchaseCategoryService, + purchaseCategoryValidator: purchaseCategoryValidator, + } +} + +func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.CreatePurchaseCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory") + return + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory") + return + } + + response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + var req contract.UpdatePurchaseCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory") + return + } + + response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory") + return + } + + response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory") +} + +func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + req := &contract.ListPurchaseCategoriesRequest{ + Page: 1, + Limit: 100, + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + req.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + if parentIDStr := c.Query("parent_id"); parentIDStr != "" { + if parentID, err := uuid.Parse(parentIDStr); err == nil { + req.ParentID = &parentID + } + } + + if categoryType := c.Query("type"); categoryType != "" { + req.Type = categoryType + } + + if search := c.Query("search"); search != "" { + req.Search = search + } + + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + req.IsActive = &isActive + } + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories") + return + } + + response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories") +} diff --git a/internal/mappers/purchase_category_mapper.go b/internal/mappers/purchase_category_mapper.go new file mode 100644 index 0000000..a781331 --- /dev/null +++ b/internal/mappers/purchase_category_mapper.go @@ -0,0 +1,53 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory { + if req == nil { + return nil + } + + return &entities.PurchaseCategory{ + OrganizationID: req.OrganizationID, + ParentID: req.ParentID, + Name: req.Name, + Type: entities.PurchaseCategoryType(req.Type), + SortOrder: req.SortOrder, + IsActive: req.IsActive, + } +} + +func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse { + if entity == nil { + return nil + } + + return &models.PurchaseCategoryResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + PresetID: entity.PresetID, + ParentID: entity.ParentID, + Code: entity.Code, + Name: entity.Name, + Type: string(entity.Type), + SortOrder: entity.SortOrder, + IsSystem: entity.IsSystem, + IsActive: entity.IsActive, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse { + responses := make([]models.PurchaseCategoryResponse, len(categoryEntities)) + for i, entity := range categoryEntities { + response := PurchaseCategoryEntityToResponse(entity) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/models/purchase_category.go b/internal/models/purchase_category.go new file mode 100644 index 0000000..bb2f26e --- /dev/null +++ b/internal/models/purchase_category.go @@ -0,0 +1,51 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type PurchaseCategoryResponse struct { + ID uuid.UUID + OrganizationID uuid.UUID + PresetID *uuid.UUID + ParentID *uuid.UUID + Code string + Name string + Type string + SortOrder int + IsSystem bool + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreatePurchaseCategoryRequest struct { + OrganizationID uuid.UUID + ParentID *uuid.UUID + Code *string + Name string + Type string + SortOrder int + IsActive bool +} + +type UpdatePurchaseCategoryRequest struct { + ParentID *uuid.UUID + Code *string + Name *string + Type *string + SortOrder *int + IsActive *bool +} + +type ListPurchaseCategoriesRequest struct { + OrganizationID uuid.UUID + ParentID *uuid.UUID + Type string + Search string + IsActive *bool + Page int + Limit int +} diff --git a/internal/processor/purchase_category_processor.go b/internal/processor/purchase_category_processor.go new file mode 100644 index 0000000..feda869 --- /dev/null +++ b/internal/processor/purchase_category_processor.go @@ -0,0 +1,210 @@ +package processor + +import ( + "context" + "fmt" + "strings" + "unicode" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type PurchaseCategoryProcessor interface { + CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) + UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) + DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error + GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) + ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) +} + +type PurchaseCategoryRepository interface { + Create(ctx context.Context, category *entities.PurchaseCategory) error + GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error) + Update(ctx context.Context, category *entities.PurchaseCategory) error + SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error + List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error) + ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error) +} + +type PurchaseCategoryProcessorImpl struct { + purchaseCategoryRepo PurchaseCategoryRepository +} + +func NewPurchaseCategoryProcessorImpl(purchaseCategoryRepo PurchaseCategoryRepository) *PurchaseCategoryProcessorImpl { + return &PurchaseCategoryProcessorImpl{purchaseCategoryRepo: purchaseCategoryRepo} +} + +func (p *PurchaseCategoryProcessorImpl) CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) { + code := "" + if req.Code != nil && strings.TrimSpace(*req.Code) != "" { + code = normalizePurchaseCategoryCode(*req.Code) + } else { + code = normalizePurchaseCategoryCode(req.Name) + } + + if code == "" { + return nil, fmt.Errorf("purchase category code cannot be empty") + } + + exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, req.OrganizationID, code, nil) + if err != nil { + return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("purchase category with code '%s' already exists", code) + } + + if req.ParentID != nil { + parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, req.OrganizationID) + if err != nil { + return nil, fmt.Errorf("parent purchase category not found: %w", err) + } + if string(parent.Type) != req.Type { + return nil, fmt.Errorf("parent purchase category type must match child type") + } + } + + category := mappers.CreatePurchaseCategoryRequestToEntity(req) + category.Code = code + + if err := p.purchaseCategoryRepo.Create(ctx, category); err != nil { + return nil, fmt.Errorf("failed to create purchase category: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("purchase category not found: %w", err) + } + + newType := string(category.Type) + if req.Type != nil { + newType = *req.Type + } + + if req.ParentID != nil { + if *req.ParentID == id { + return nil, fmt.Errorf("purchase category cannot be its own parent") + } + + parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, organizationID) + if err != nil { + return nil, fmt.Errorf("parent purchase category not found: %w", err) + } + if string(parent.Type) != newType { + return nil, fmt.Errorf("parent purchase category type must match child type") + } + category.ParentID = req.ParentID + } + + if req.Code != nil { + code := normalizePurchaseCategoryCode(*req.Code) + if code == "" { + return nil, fmt.Errorf("purchase category code cannot be empty") + } + + if code != category.Code { + exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, organizationID, code, &id) + if err != nil { + return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("purchase category with code '%s' already exists", code) + } + category.Code = code + } + } + + if req.Name != nil { + category.Name = strings.TrimSpace(*req.Name) + } + if req.Type != nil { + category.Type = entities.PurchaseCategoryType(*req.Type) + } + if req.SortOrder != nil { + category.SortOrder = *req.SortOrder + } + if req.IsActive != nil { + category.IsActive = *req.IsActive + } + + if err := p.purchaseCategoryRepo.Update(ctx, category); err != nil { + return nil, fmt.Errorf("failed to update purchase category: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error { + _, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found: %w", err) + } + + if err := p.purchaseCategoryRepo.SoftDelete(ctx, id, organizationID); err != nil { + return fmt.Errorf("failed to delete purchase category: %w", err) + } + + return nil +} + +func (p *PurchaseCategoryProcessorImpl) GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("purchase category not found: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) { + filters := make(map[string]interface{}) + if req.ParentID != nil { + filters["parent_id"] = *req.ParentID + } + if req.Type != "" { + filters["type"] = req.Type + } + if req.Search != "" { + filters["search"] = req.Search + } + if req.IsActive != nil { + filters["is_active"] = *req.IsActive + } + + offset := (req.Page - 1) * req.Limit + categories, total, err := p.purchaseCategoryRepo.List(ctx, req.OrganizationID, filters, req.Limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list purchase categories: %w", err) + } + + return mappers.PurchaseCategoryEntitiesToResponses(categories), int(total), nil +} + +func normalizePurchaseCategoryCode(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + var builder strings.Builder + lastUnderscore := false + + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + builder.WriteRune(r) + lastUnderscore = false + continue + } + + if !lastUnderscore { + builder.WriteRune('_') + lastUnderscore = true + } + } + + return strings.Trim(builder.String(), "_") +} diff --git a/internal/repository/purchase_category_repository.go b/internal/repository/purchase_category_repository.go new file mode 100644 index 0000000..331a423 --- /dev/null +++ b/internal/repository/purchase_category_repository.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PurchaseCategoryRepositoryImpl struct { + db *gorm.DB +} + +func NewPurchaseCategoryRepositoryImpl(db *gorm.DB) *PurchaseCategoryRepositoryImpl { + return &PurchaseCategoryRepositoryImpl{db: db} +} + +func (r *PurchaseCategoryRepositoryImpl) Create(ctx context.Context, category *entities.PurchaseCategory) error { + return r.db.WithContext(ctx).Create(category).Error +} + +func (r *PurchaseCategoryRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error) { + var category entities.PurchaseCategory + err := r.db.WithContext(ctx). + First(&category, "id = ? AND organization_id = ?", id, organizationID).Error + if err != nil { + return nil, err + } + return &category, nil +} + +func (r *PurchaseCategoryRepositoryImpl) Update(ctx context.Context, category *entities.PurchaseCategory) error { + return r.db.WithContext(ctx).Save(category).Error +} + +func (r *PurchaseCategoryRepositoryImpl) SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error { + return r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("id = ? AND organization_id = ?", id, organizationID). + Update("is_active", false).Error +} + +func (r *PurchaseCategoryRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error) { + var categories []*entities.PurchaseCategory + var total int64 + + query := r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("organization_id = ?", organizationID) + + for key, value := range filters { + switch key { + case "search": + searchValue := "%" + value.(string) + "%" + query = query.Where("name ILIKE ? OR code ILIKE ?", searchValue, searchValue) + case "parent_id": + query = query.Where("parent_id = ?", value) + case "type": + query = query.Where("type = ?", value) + case "is_active": + query = query.Where("is_active = ?", value) + } + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query. + Order("parent_id NULLS FIRST, sort_order ASC, name ASC"). + Limit(limit). + Offset(offset). + Find(&categories).Error + return categories, total, err +} + +func (r *PurchaseCategoryRepositoryImpl) ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error) { + query := r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("organization_id = ? AND code = ?", organizationID, code) + + if excludeID != nil { + query = query.Where("id != ?", *excludeID) + } + + var count int64 + err := query.Count(&count).Error + return count > 0, err +} diff --git a/internal/router/router.go b/internal/router/router.go index f9fdee7..d3efbcd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -35,6 +35,7 @@ type Router struct { productRecipeHandler *handler.ProductRecipeHandler vendorHandler *handler.VendorHandler purchaseOrderHandler *handler.PurchaseOrderHandler + purchaseCategoryHandler *handler.PurchaseCategoryHandler unitConverterHandler *handler.IngredientUnitConverterHandler chartOfAccountTypeHandler *handler.ChartOfAccountTypeHandler chartOfAccountHandler *handler.ChartOfAccountHandler @@ -55,7 +56,7 @@ type Router struct { customerAuthMiddleware *middleware.CustomerAuthMiddleware } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { return &Router{ config: cfg, @@ -80,6 +81,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService), vendorHandler: handler.NewVendorHandler(vendorService, vendorValidator), purchaseOrderHandler: handler.NewPurchaseOrderHandler(purchaseOrderService, purchaseOrderValidator), + purchaseCategoryHandler: handler.NewPurchaseCategoryHandler(purchaseCategoryService, purchaseCategoryValidator), unitConverterHandler: handler.NewIngredientUnitConverterHandler(unitConverterService, unitConverterValidator), chartOfAccountTypeHandler: handler.NewChartOfAccountTypeHandler(chartOfAccountTypeService, chartOfAccountTypeValidator), chartOfAccountHandler: handler.NewChartOfAccountHandler(chartOfAccountService, chartOfAccountValidator), @@ -384,6 +386,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { purchaseOrders.DELETE("/:id", r.purchaseOrderHandler.DeletePurchaseOrder) } + purchaseCategories := protected.Group("/purchase-categories") + purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager()) + { + purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory) + purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories) + purchaseCategories.GET("/:id", r.purchaseCategoryHandler.GetPurchaseCategory) + purchaseCategories.PUT("/:id", r.purchaseCategoryHandler.UpdatePurchaseCategory) + purchaseCategories.DELETE("/:id", r.purchaseCategoryHandler.DeletePurchaseCategory) + } + unitConverters := protected.Group("/unit-converters") unitConverters.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/purchase_category_service.go b/internal/service/purchase_category_service.go new file mode 100644 index 0000000..eacc77c --- /dev/null +++ b/internal/service/purchase_category_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type PurchaseCategoryService interface { + CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response + UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response + DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response +} + +type PurchaseCategoryServiceImpl struct { + purchaseCategoryProcessor processor.PurchaseCategoryProcessor +} + +func NewPurchaseCategoryService(purchaseCategoryProcessor processor.PurchaseCategoryProcessor) *PurchaseCategoryServiceImpl { + return &PurchaseCategoryServiceImpl{purchaseCategoryProcessor: purchaseCategoryProcessor} +} + +func (s *PurchaseCategoryServiceImpl) CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response { + modelReq := transformer.CreatePurchaseCategoryRequestToModel(apctx, req) + + category, err := s.purchaseCategoryProcessor.CreatePurchaseCategory(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response { + modelReq := transformer.UpdatePurchaseCategoryRequestToModel(req) + + category, err := s.purchaseCategoryProcessor.UpdatePurchaseCategory(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + err := s.purchaseCategoryProcessor.DeletePurchaseCategory(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Purchase category deleted successfully", + }) +} + +func (s *PurchaseCategoryServiceImpl) GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + category, err := s.purchaseCategoryProcessor.GetPurchaseCategoryByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response { + modelReq := &models.ListPurchaseCategoriesRequest{ + OrganizationID: apctx.OrganizationID, + ParentID: req.ParentID, + Type: req.Type, + Search: req.Search, + IsActive: req.IsActive, + Page: req.Page, + Limit: req.Limit, + } + + categories, totalCount, err := s.purchaseCategoryProcessor.ListPurchaseCategories(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + totalPages := totalCount / req.Limit + if totalCount%req.Limit > 0 { + totalPages++ + } + + return contract.BuildSuccessResponse(&contract.ListPurchaseCategoriesResponse{ + PurchaseCategories: transformer.PurchaseCategoryModelResponsesToResponses(categories), + TotalCount: totalCount, + Page: req.Page, + Limit: req.Limit, + TotalPages: totalPages, + }) +} diff --git a/internal/transformer/purchase_category_transformer.go b/internal/transformer/purchase_category_transformer.go new file mode 100644 index 0000000..24f3d97 --- /dev/null +++ b/internal/transformer/purchase_category_transformer.go @@ -0,0 +1,72 @@ +package transformer + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreatePurchaseCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *models.CreatePurchaseCategoryRequest { + sortOrder := 0 + if req.SortOrder != nil { + sortOrder = *req.SortOrder + } + + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + return &models.CreatePurchaseCategoryRequest{ + OrganizationID: apctx.OrganizationID, + ParentID: req.ParentID, + Code: req.Code, + Name: req.Name, + Type: req.Type, + SortOrder: sortOrder, + IsActive: isActive, + } +} + +func UpdatePurchaseCategoryRequestToModel(req *contract.UpdatePurchaseCategoryRequest) *models.UpdatePurchaseCategoryRequest { + return &models.UpdatePurchaseCategoryRequest{ + ParentID: req.ParentID, + Code: req.Code, + Name: req.Name, + Type: req.Type, + SortOrder: req.SortOrder, + IsActive: req.IsActive, + } +} + +func PurchaseCategoryModelResponseToResponse(category *models.PurchaseCategoryResponse) *contract.PurchaseCategoryResponse { + if category == nil { + return nil + } + + return &contract.PurchaseCategoryResponse{ + ID: category.ID, + OrganizationID: category.OrganizationID, + PresetID: category.PresetID, + ParentID: category.ParentID, + Code: category.Code, + Name: category.Name, + Type: category.Type, + SortOrder: category.SortOrder, + IsSystem: category.IsSystem, + IsActive: category.IsActive, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + } +} + +func PurchaseCategoryModelResponsesToResponses(categories []models.PurchaseCategoryResponse) []contract.PurchaseCategoryResponse { + responses := make([]contract.PurchaseCategoryResponse, len(categories)) + for i, category := range categories { + response := PurchaseCategoryModelResponseToResponse(&category) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/validator/purchase_category_validator.go b/internal/validator/purchase_category_validator.go new file mode 100644 index 0000000..10b1f5c --- /dev/null +++ b/internal/validator/purchase_category_validator.go @@ -0,0 +1,103 @@ +package validator + +import ( + "errors" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +type PurchaseCategoryValidator interface { + ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string) + ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string) + ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string) +} + +type PurchaseCategoryValidatorImpl struct{} + +func NewPurchaseCategoryValidator() *PurchaseCategoryValidatorImpl { + return &PurchaseCategoryValidatorImpl{} +} + +func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.Name) == "" { + return errors.New("name is required"), constants.MissingFieldErrorCode + } + + if len(req.Name) > 255 { + return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode + } + + if !isValidPurchaseCategoryType(req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { + return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.ParentID == nil && req.Code == nil && req.Name == nil && req.Type == nil && req.SortOrder == nil && req.IsActive == nil { + return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode + } + + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + return errors.New("name cannot be empty"), constants.MalformedFieldErrorCode + } + if len(*req.Name) > 255 { + return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode + } + } + + if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { + return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + if req.Type != "" && !isValidPurchaseCategoryType(req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func isValidPurchaseCategoryType(categoryType string) bool { + switch categoryType { + case "raw_material", "non_inventory": + return true + default: + return false + } +} diff --git a/migrations/000075_create_purchase_categories.down.sql b/migrations/000075_create_purchase_categories.down.sql new file mode 100644 index 0000000..350087f --- /dev/null +++ b/migrations/000075_create_purchase_categories.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS purchase_categories; +DROP TABLE IF EXISTS purchase_category_presets; diff --git a/migrations/000075_create_purchase_categories.up.sql b/migrations/000075_create_purchase_categories.up.sql new file mode 100644 index 0000000..2f78bc6 --- /dev/null +++ b/migrations/000075_create_purchase_categories.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE purchase_category_presets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, + code VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE purchase_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + preset_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, + parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL, + code VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_system BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (organization_id, code) +); + +CREATE INDEX idx_purchase_category_presets_parent_id ON purchase_category_presets(parent_id); +CREATE INDEX idx_purchase_category_presets_type ON purchase_category_presets(type); +CREATE INDEX idx_purchase_category_presets_is_active ON purchase_category_presets(is_active); + +CREATE INDEX idx_purchase_categories_organization_id ON purchase_categories(organization_id); +CREATE INDEX idx_purchase_categories_preset_id ON purchase_categories(preset_id); +CREATE INDEX idx_purchase_categories_parent_id ON purchase_categories(parent_id); +CREATE INDEX idx_purchase_categories_type ON purchase_categories(type); +CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active); + +INSERT INTO purchase_category_presets (code, name, type, sort_order) +VALUES + ('hpp', 'HPP', 'raw_material', 1), + ('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order) +SELECT parent.id, child.code, child.name, child.type, child.sort_order +FROM purchase_category_presets parent +JOIN ( + VALUES + ('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), + ('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), + ('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), + ('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), + ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), + ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), + ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), + ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), + ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), + ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6), + ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7), + ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8), + ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9) +) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code +ON CONFLICT (code) DO NOTHING; diff --git a/migrations/000076_add_default_purchase_categories_trigger.down.sql b/migrations/000076_add_default_purchase_categories_trigger.down.sql new file mode 100644 index 0000000..ccfc82e --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trigger_create_default_purchase_categories ON organizations; +DROP FUNCTION IF EXISTS create_default_purchase_categories(); diff --git a/migrations/000076_add_default_purchase_categories_trigger.up.sql b/migrations/000076_add_default_purchase_categories_trigger.up.sql new file mode 100644 index 0000000..d5ff930 --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.up.sql @@ -0,0 +1,49 @@ +CREATE OR REPLACE FUNCTION create_default_purchase_categories() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) + SELECT + NEW.id, + preset.id, + NULL, + preset.code, + preset.name, + preset.type, + preset.sort_order, + true, + preset.is_active, + NOW(), + NOW() + FROM purchase_category_presets preset + WHERE preset.parent_id IS NULL + AND preset.is_active = true + ON CONFLICT (organization_id, code) DO NOTHING; + + INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) + SELECT + NEW.id, + child_preset.id, + parent_category.id, + child_preset.code, + child_preset.name, + child_preset.type, + child_preset.sort_order, + true, + child_preset.is_active, + NOW(), + NOW() + FROM purchase_category_presets child_preset + JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id + JOIN purchase_categories parent_category ON parent_category.organization_id = NEW.id + AND parent_category.code = parent_preset.code + WHERE child_preset.is_active = true + ON CONFLICT (organization_id, code) DO NOTHING; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_create_default_purchase_categories + AFTER INSERT ON organizations + FOR EACH ROW + EXECUTE FUNCTION create_default_purchase_categories(); diff --git a/migrations/000077_backfill_default_purchase_categories.down.sql b/migrations/000077_backfill_default_purchase_categories.down.sql new file mode 100644 index 0000000..baf5349 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.down.sql @@ -0,0 +1,3 @@ +DELETE FROM purchase_categories +WHERE is_system = true + AND preset_id IS NOT NULL; diff --git a/migrations/000077_backfill_default_purchase_categories.up.sql b/migrations/000077_backfill_default_purchase_categories.up.sql new file mode 100644 index 0000000..9901256 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.up.sql @@ -0,0 +1,39 @@ +INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) +SELECT + org.id, + preset.id, + NULL, + preset.code, + preset.name, + preset.type, + preset.sort_order, + true, + preset.is_active, + NOW(), + NOW() +FROM organizations org +CROSS JOIN purchase_category_presets preset +WHERE preset.parent_id IS NULL + AND preset.is_active = true +ON CONFLICT (organization_id, code) DO NOTHING; + +INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) +SELECT + org.id, + child_preset.id, + parent_category.id, + child_preset.code, + child_preset.name, + child_preset.type, + child_preset.sort_order, + true, + child_preset.is_active, + NOW(), + NOW() +FROM organizations org +JOIN purchase_category_presets child_preset ON child_preset.parent_id IS NOT NULL +JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id +JOIN purchase_categories parent_category ON parent_category.organization_id = org.id + AND parent_category.code = parent_preset.code +WHERE child_preset.is_active = true +ON CONFLICT (organization_id, code) DO NOTHING;