diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index c4291619..985349f0 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -30,12 +30,17 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), + Search: strings.TrimSpace(c.Query("search")), + ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), + PoDate: strings.TrimSpace(c.Query("po_date")), + PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), + PoDateTo: strings.TrimSpace(c.Query("po_date_to")), CreatedFrom: strings.TrimSpace(c.Query("created_from")), CreatedTo: strings.TrimSpace(c.Query("created_to")), SupplierID: uint(c.QueryInt("supplier_id", 0)), AreaID: uint(c.QueryInt("area_id", 0)), LocationID: uint(c.QueryInt("location_id", 0)), - ProductCategoryID: uint(c.QueryInt("product_category_id", 0)), + ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 5b419482..a48e103d 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -7,6 +7,7 @@ import ( "math" "mime/multipart" "sort" + "strconv" "strings" "time" @@ -146,6 +147,35 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, utils.BadRequest(err.Error()) } + productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id") + if err != nil { + return nil, 0, utils.BadRequest(err.Error()) + } + + var poDateStart *time.Time + var poDateEnd *time.Time + + if strings.TrimSpace(params.PoDate) != "" { + poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate)) + if parseErr != nil { + return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD") + } + poDateStart = &poDate + poDateEndValue := poDate.AddDate(0, 0, 1) + poDateEnd = &poDateEndValue + } else { + poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo) + if err != nil { + return nil, 0, utils.BadRequest(err.Error()) + } + } + + search := strings.ToLower(strings.TrimSpace(params.Search)) + approvalStatuses := parseStringCSVFilter(params.ApprovalStatus) + for i := range approvalStatuses { + approvalStatuses[i] = normalizeApprovalStatusFilter(approvalStatuses[i]) + } + purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) db = db.Where("purchases.deleted_at IS NULL") @@ -161,7 +191,13 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if createdTo != nil { db = db.Where("created_at < ?", *createdTo) } + if poDateStart != nil { + db = db.Where("purchases.po_date >= ?", *poDateStart) + } + if poDateEnd != nil { + db = db.Where("purchases.po_date < ?", *poDateEnd) + } if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 0") @@ -201,15 +237,86 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti ) } - if params.ProductCategoryID > 0 { + if len(productCategoryIDs) > 0 { db = db.Where( `EXISTS ( SELECT 1 FROM purchase_items pi JOIN products p ON p.id = pi.product_id - WHERE pi.purchase_id = purchases.id AND p.product_category_id = ? + WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ? )`, - params.ProductCategoryID, + productCategoryIDs, + ) + } + + if len(approvalStatuses) > 0 { + approvalConditions := make([]string, 0, len(approvalStatuses)) + approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3)) + approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String()) + for _, status := range approvalStatuses { + if status == "" { + continue + } + like := "%" + status + "%" + approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`) + approvalArgs = append(approvalArgs, like, like, status) + } + + if len(approvalConditions) > 0 { + approvalClause := strings.Join(approvalConditions, " OR ") + approvalQuery := fmt.Sprintf( + `EXISTS ( + SELECT 1 + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = purchases.id + AND a.id = ( + SELECT a2.id + FROM approvals a2 + WHERE a2.approvable_type = ? + AND a2.approvable_id = purchases.id + ORDER BY a2.action_at DESC, a2.id DESC + LIMIT 1 + ) + AND (%s) + )`, + approvalClause, + ) + db = db.Where(approvalQuery, approvalArgs...) + } + } + + if search != "" { + like := "%" + search + "%" + db = db.Where( + `( + LOWER(COALESCE(purchases.pr_number, '')) LIKE ? + OR LOWER(COALESCE(purchases.po_number, '')) LIKE ? + OR EXISTS ( + SELECT 1 + FROM suppliers s + WHERE s.id = purchases.supplier_id + AND LOWER(COALESCE(s.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM users u + WHERE u.id = purchases.created_by + AND LOWER(COALESCE(u.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN products p ON p.id = pi.product_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(p.name, '')) LIKE ? + ) + )`, + like, + like, + like, + like, + like, ) } @@ -221,12 +328,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, utils.Internal("Failed to get purchases") } - for i := range purchases { - if err := s.attachLatestApproval(c.Context(), &purchases[i]); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", purchases[i].Id, err) - } + if err := s.attachLatestApprovals(c.Context(), purchases); err != nil { + s.Log.Warnf("Unable to attach latest approvals for purchases: %+v", err) } - return purchases, total, nil } @@ -2028,6 +2132,123 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity return nil } +func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + for i := range items { + if items[i].Id == 0 { + continue + } + ids = append(ids, items[i].Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + return err + } + + for i := range items { + items[i].LatestApproval = latestMap[items[i].Id] + } + + return nil +} + +func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + if strings.TrimSpace(fromStr) != "" { + parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr)) + if err != nil { + return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD") + } + fromValue := parsed + fromPtr = &fromValue + } + + if strings.TrimSpace(toStr) != "" { + parsed, err := utils.ParseDateString(strings.TrimSpace(toStr)) + if err != nil { + return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD") + } + nextDay := parsed.AddDate(0, 0, 1) + toPtr = &nextDay + } + + if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { + return nil, nil, errors.New("po_date_from must be earlier than po_date_to") + } + + return fromPtr, toPtr, nil +} + +func normalizeApprovalStatusFilter(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + switch value { + case "disetujui": + return "approved" + case "ditolak": + return "rejected" + default: + return value + } +} + +func parseUintCSVFilter(raw, fieldName string) ([]uint, error) { + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}, len(parts)) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + num, err := strconv.ParseUint(value, 10, 64) + if err != nil || num == 0 { + return nil, fmt.Errorf("%s contains invalid value: %s", fieldName, value) + } + u := uint(num) + if _, exists := seen[u]; exists { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + + return result, nil +} + +func parseStringCSVFilter(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + + for _, part := range parts { + value := strings.ToLower(strings.TrimSpace(part)) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + + return result +} + func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { value := strings.ToUpper(strings.TrimSpace(raw)) switch value { diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 6f5d3013..b643501c 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -66,7 +66,11 @@ type Query struct { SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` AreaID uint `query:"area_id" validate:"omitempty,gt=0"` LocationID uint `query:"location_id" validate:"omitempty,gt=0"` - ProductCategoryID uint `query:"product_category_id" validate:"omitempty,gt=0"` + ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` + ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` + PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` + PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` + PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` Search string `query:"search" validate:"omitempty,max=100"` CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index aff0a718..5e33d2a0 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "math" "strconv" "strings" @@ -158,17 +159,34 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { } func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { + areaIDs, err := parseCommaSeparatedInt64sWithField(ctx.Query("area_id", ""), "area_id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + supplierIDs, err := parseCommaSeparatedInt64sWithField(ctx.Query("supplier_id", ""), "supplier_id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + productIDs, err := parseCommaSeparatedInt64sWithField(ctx.Query("product_id", ""), "product_id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + productCategoryIDs, err := parseCommaSeparatedInt64sWithField(ctx.Query("product_category_id", ""), "product_category_id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + query := &validation.PurchaseSupplierQuery{ - Page: ctx.QueryInt("page", 1), - Limit: ctx.QueryInt("limit", 10), - AreaId: int64(ctx.QueryInt("area_id", 0)), - SupplierId: int64(ctx.QueryInt("supplier_id", 0)), - ProductId: int64(ctx.QueryInt("product_id", 0)), - ProductCategoryId: int64(ctx.QueryInt("product_category_id", 0)), - StartDate: ctx.Query("start_date", ""), - EndDate: ctx.Query("end_date", ""), - SortBy: ctx.Query("sort_by", ""), - FilterBy: ctx.Query("filter_by", ""), + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + AreaIDs: areaIDs, + SupplierIDs: supplierIDs, + ProductIDs: productIDs, + ProductCategoryIDs: productCategoryIDs, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + SortBy: ctx.Query("sort_by", ""), + FilterBy: ctx.Query("filter_by", ""), } areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) @@ -189,10 +207,10 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { } filters := map[string]interface{}{ - "area_id": query.AreaId, - "supplier_id": query.SupplierId, - "product_id": query.ProductId, - "product_category_id": query.ProductCategoryId, + "area_id": query.AreaIDs, + "supplier_id": query.SupplierIDs, + "product_id": query.ProductIDs, + "product_category_id": query.ProductCategoryIDs, "start_date": query.StartDate, "end_date": query.EndDate, "sort_by": query.SortBy, @@ -412,6 +430,9 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { } func parseCommaSeparatedInt64s(raw string) ([]int64, error) { + return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") +} +func parseCommaSeparatedInt64sWithField(raw, field string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { return []int64{}, nil @@ -427,7 +448,7 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { id, err := strconv.ParseInt(part, 10, 64) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "supplier_ids must be comma separated integers") + return nil, fmt.Errorf("%s must be comma separated integers", field) } result = append(result, id) } diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 3206eaa5..d4860d3d 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -60,24 +60,24 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Where("purchase_items.received_date IS NOT NULL") - if filters.SupplierId > 0 { - db = db.Where("suppliers.id = ?", filters.SupplierId) + if len(filters.SupplierIDs) > 0 { + db = db.Where("suppliers.id IN ?", filters.SupplierIDs) } - if filters.ProductId > 0 { - db = db.Where("purchase_items.product_id = ?", filters.ProductId) + if len(filters.ProductIDs) > 0 { + db = db.Where("purchase_items.product_id IN ?", filters.ProductIDs) } - if filters.ProductCategoryId > 0 { + if len(filters.ProductCategoryIDs) > 0 { db = db. Joins("JOIN products ON products.id = purchase_items.product_id"). - Where("products.product_category_id = ?", filters.ProductCategoryId) + Where("products.product_category_id IN ?", filters.ProductCategoryIDs) } - if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + if len(filters.AreaIDs) > 0 || filters.AllowedAreaIDs != nil { db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") - if filters.AreaId > 0 { - db = db.Where("warehouses.area_id = ?", filters.AreaId) + if len(filters.AreaIDs) > 0 { + db = db.Where("warehouses.area_id IN ?", filters.AreaIDs) } if filters.AllowedAreaIDs != nil { if len(filters.AllowedAreaIDs) == 0 { @@ -187,20 +187,19 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Where("purchase_items.received_date IS NOT NULL") - if filters.ProductId > 0 { - db = db.Where("purchase_items.product_id = ?", filters.ProductId) + if len(filters.ProductIDs) > 0 { + db = db.Where("purchase_items.product_id IN ?", filters.ProductIDs) } - if filters.ProductCategoryId > 0 { + if len(filters.ProductCategoryIDs) > 0 { db = db. Joins("JOIN products ON products.id = purchase_items.product_id"). - Where("products.product_category_id = ?", filters.ProductCategoryId) + Where("products.product_category_id IN ?", filters.ProductCategoryIDs) } - - if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + if len(filters.AreaIDs) > 0 || filters.AllowedAreaIDs != nil { db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") - if filters.AreaId > 0 { - db = db.Where("warehouses.area_id = ?", filters.AreaId) + if len(filters.AreaIDs) > 0 { + db = db.Where("warehouses.area_id IN ?", filters.AreaIDs) } if filters.AllowedAreaIDs != nil { if len(filters.AllowedAreaIDs) == 0 { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 97ea60fa..d248c779 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -38,17 +38,17 @@ type MarketingQuery struct { } type PurchaseSupplierQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` - AreaId int64 `query:"area_id" validate:"omitempty"` - SupplierId int64 `query:"supplier_id" validate:"omitempty"` - ProductId int64 `query:"product_id" validate:"omitempty"` - ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"` - StartDate string `query:"start_date" validate:"omitempty"` - EndDate string `query:"end_date" validate:"omitempty"` - SortBy string `query:"sort_by" validate:"omitempty"` - FilterBy string `query:"filter_by" validate:"omitempty"` - AllowedAreaIDs []int64 `query:"-"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + AreaIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + ProductIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + ProductCategoryIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty"` + FilterBy string `query:"filter_by" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct {