package validator import ( "errors" "strconv" "strings" "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "github.com/google/uuid" ) type PurchaseOrderValidator interface { ValidateCreatePurchaseOrderRequest(req *contract.CreatePurchaseOrderRequest) (error, string) ValidateUpdatePurchaseOrderRequest(req *contract.UpdatePurchaseOrderRequest) (error, string) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) } type PurchaseOrderValidatorImpl struct{} func NewPurchaseOrderValidator() *PurchaseOrderValidatorImpl { return &PurchaseOrderValidatorImpl{} } func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *contract.CreatePurchaseOrderRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode } if req.VendorID != nil && *req.VendorID == uuid.Nil { return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode } if strings.TrimSpace(req.PONumber) == "" { return errors.New("po_number is required"), constants.MissingFieldErrorCode } if len(req.PONumber) < 1 || len(req.PONumber) > 50 { return errors.New("po_number must be between 1 and 50 characters"), constants.MalformedFieldErrorCode } // Validate transaction date if strings.TrimSpace(req.TransactionDate) == "" { return errors.New("transaction_date is required"), constants.MissingFieldErrorCode } transactionDate, err := time.Parse("2006-01-02", req.TransactionDate) if err != nil { return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } if req.DueDate != nil { if strings.TrimSpace(*req.DueDate) == "" { return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode } dueDate, err := time.Parse("2006-01-02", *req.DueDate) if err != nil { return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } if dueDate.Before(transactionDate) { return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode } } if req.Reference != nil && len(*req.Reference) > 100 { return errors.New("reference must be at most 100 characters"), constants.MalformedFieldErrorCode } if req.Status != nil { validStatuses := []string{"draft", "sent", "approved", "received", "cancelled"} if !contains(validStatuses, *req.Status) { return errors.New("status must be one of: draft, sent, approved, received, cancelled"), constants.MalformedFieldErrorCode } } if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil { return err, code } if len(req.Items) == 0 { return errors.New("at least one item is required"), constants.MissingFieldErrorCode } // Validate items for i, item := range req.Items { if err, code := v.validatePurchaseOrderItem(&item, i); err != nil { return err, code } } return nil, "" } func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *contract.UpdatePurchaseOrderRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode } if req.PONumber != nil { if strings.TrimSpace(*req.PONumber) == "" { return errors.New("po_number cannot be empty"), constants.MalformedFieldErrorCode } if len(*req.PONumber) < 1 || len(*req.PONumber) > 50 { return errors.New("po_number must be between 1 and 50 characters"), constants.MalformedFieldErrorCode } } var transactionDate *time.Time if req.TransactionDate != nil && *req.TransactionDate != "" { parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate) if err != nil { return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } transactionDate = &parsedDate } if req.DueDate != nil { if strings.TrimSpace(*req.DueDate) == "" { return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode } dueDate, err := time.Parse("2006-01-02", *req.DueDate) if err != nil { return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } if transactionDate != nil && dueDate.Before(*transactionDate) { return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode } } if req.Reference != nil && len(*req.Reference) > 100 { return errors.New("reference must be at most 100 characters"), constants.MalformedFieldErrorCode } if req.Status != nil { validStatuses := []string{"draft", "sent", "approved", "received", "cancelled"} if !contains(validStatuses, *req.Status) { return errors.New("status must be one of: draft, sent, approved, received, cancelled"), constants.MalformedFieldErrorCode } } if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil { return err, code } // Validate items if provided if req.Items != nil { for i, item := range req.Items { if err, code := v.validateUpdatePurchaseOrderItem(&item, i); err != nil { return err, code } } } return nil, "" } // validatePurchaseTeamSelection keeps team_scope and team_category_id in step with // the database check constraint: a category team needs a category, Pusat must not // carry one. allowClear lets an update send an empty scope to drop the team. func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) { if scope == nil { if categoryID != nil { return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode } return nil, "" } switch strings.TrimSpace(*scope) { case "": if !allowClear { return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode } if categoryID != nil { return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode } case constants.PurchaseTeamScopeCategory: if categoryID == nil || *categoryID == uuid.Nil { return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode } case constants.PurchaseTeamScopeCentral: if categoryID != nil { return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode } default: return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode } return nil, "" } func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) { if req == nil { return errors.New("request body 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.Status != "" { validStatuses := []string{"draft", "sent", "approved", "received", "cancelled"} if !contains(validStatuses, req.Status) { return errors.New("status must be one of: draft, sent, approved, received, cancelled"), constants.MalformedFieldErrorCode } } if req.TeamScope != "" { validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} if !contains(validScopes, req.TeamScope) { return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode } if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil { return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode } } if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil { return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode } if req.StartDate != nil && req.EndDate != nil { if req.EndDate.Before(*req.StartDate) { return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode } } return nil, "" } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { if item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } if item.IngredientID != nil && *item.IngredientID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode } if item.Quantity != nil && *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } if item.UnitID != nil && *item.UnitID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode } if item.Amount < 0 { return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode } return nil, "" } func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) { if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } if item.IngredientID != nil && *item.IngredientID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode } if item.UnitID != nil && *item.UnitID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode } if item.Quantity != nil && *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } if item.Amount != nil && *item.Amount < 0 { return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode } return nil, "" } // Helper function to check if a string is in a slice func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false }