Merge branch 'production' into 'development'

Production (Back Merge after Hotfixes)

See merge request mbugroup/lti-api!390
This commit is contained in:
Adnan Zahir
2026-04-04 09:55:16 +07:00
9 changed files with 381 additions and 67 deletions
@@ -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 {
@@ -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 {
@@ -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"`