diff --git a/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.down.sql b/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.down.sql new file mode 100644 index 00000000..e4be48f1 --- /dev/null +++ b/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS daily_checklist_empty_kandangs; + +COMMIT; diff --git a/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.up.sql b/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.up.sql new file mode 100644 index 00000000..bac629d3 --- /dev/null +++ b/internal/database/migrations/20260517112459_create_daily_checklist_empty_kandangs.up.sql @@ -0,0 +1,60 @@ +BEGIN; + +CREATE TABLE daily_checklist_empty_kandangs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + daily_checklist_id bigint NOT NULL, + kandang_id bigint NOT NULL, + start_date date NOT NULL, + end_date date NOT NULL, + created_by bigint, + deleted_by bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + + CONSTRAINT fk_dcek_daily_checklist + FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE, + CONSTRAINT fk_dcek_kandang + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE, + CONSTRAINT fk_dcek_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_dcek_deleted_by + FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT ck_dcek_range CHECK (end_date >= start_date) +); + +CREATE INDEX idx_dcek_kandang_range + ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique + ON daily_checklist_empty_kandangs (daily_checklist_id) + WHERE deleted_at IS NULL; + +INSERT INTO daily_checklist_empty_kandangs ( + daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at +) +SELECT + dc.id, + dc.kandang_id, + dc.date AS start_date, + COALESCE( + (SELECT (next_dc.date - INTERVAL '1 day')::date + FROM daily_checklists next_dc + WHERE next_dc.kandang_id = dc.kandang_id + AND next_dc.date > dc.date + AND next_dc.category <> 'empty_kandang' + AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED') + AND next_dc.deleted_at IS NULL + ORDER BY next_dc.date ASC + LIMIT 1), + dc.date + ) AS end_date, + dc.created_by, + dc.created_at, + dc.updated_at +FROM daily_checklists dc +WHERE dc.category = 'empty_kandang' + AND dc.deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.down.sql b/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.down.sql new file mode 100644 index 00000000..08cbd103 --- /dev/null +++ b/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE customers DROP COLUMN bank_name; +ALTER TABLE suppliers DROP COLUMN bank_name; diff --git a/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.up.sql b/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.up.sql new file mode 100644 index 00000000..5420a363 --- /dev/null +++ b/internal/database/migrations/20260517132922_add_bank_name_to_customers_suppliers.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT ''; +ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100); diff --git a/internal/entities/customer.go b/internal/entities/customer.go index f171f0ff..af4bd556 100644 --- a/internal/entities/customer.go +++ b/internal/entities/customer.go @@ -15,6 +15,7 @@ type Customer struct { Phone string `gorm:"not null;size:20"` Email string `gorm:"type:varchar(50);not null"` AccountNumber string `gorm:"not null;size:50"` + BankName string `gorm:"not null;size:100;default:''"` Balance float64 `gorm:"default:0"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/daily-checklist-empty-kandang.go b/internal/entities/daily-checklist-empty-kandang.go new file mode 100644 index 00000000..9650253a --- /dev/null +++ b/internal/entities/daily-checklist-empty-kandang.go @@ -0,0 +1,27 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type DailyChecklistEmptyKandang struct { + Id uint `gorm:"primaryKey"` + DailyChecklistId uint `gorm:"not null"` + KandangId uint `gorm:"not null"` + StartDate time.Time `gorm:"type:date;not null"` + EndDate time.Time `gorm:"type:date;not null"` + CreatedBy *uint + DeletedBy *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"` + Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"` +} + +func (DailyChecklistEmptyKandang) TableName() string { + return "daily_checklist_empty_kandangs" +} diff --git a/internal/entities/daily-checklist.go b/internal/entities/daily-checklist.go index 6c2106ae..1c2667bd 100644 --- a/internal/entities/daily-checklist.go +++ b/internal/entities/daily-checklist.go @@ -23,11 +23,12 @@ type DailyChecklist struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` - Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` - Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` - Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"` - Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` + Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` + Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` + Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"` + Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` + EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"` } type DailyChecklistPhase struct { diff --git a/internal/entities/supplier.go b/internal/entities/supplier.go index bdbb4dfe..410cc93b 100644 --- a/internal/entities/supplier.go +++ b/internal/entities/supplier.go @@ -19,6 +19,7 @@ type Supplier struct { Address string `gorm:"not null"` Npwp *string `gorm:"size:50"` AccountNumber *string `gorm:"size:50"` + BankName *string `gorm:"size:100"` Balance float64 `gorm:"type:numeric(15,3);default:0"` DueDate int `gorm:"not null"` CreatedBy uint `gorm:"not null"` diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index abc1cea1..7280badb 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -42,6 +42,13 @@ type DailyChecklistDetailDTO struct { TotalActivity int `json:"total_activity"` Progress float64 `json:"progress"` DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"` + EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"` +} + +type DailyChecklistEmptyKandangDTO struct { + Id uint `json:"id"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` } type DailyChecklistDocumentDTO struct { @@ -180,6 +187,17 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { } } +func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO { + if e == nil || e.Id == 0 { + return nil + } + return &DailyChecklistEmptyKandangDTO{ + Id: e.Id, + StartDate: e.StartDate, + EndDate: e.EndDate, + } +} + func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO { phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) for _, phase := range phases { @@ -241,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity. TotalActivity: totalActivities, Progress: progress, DocumentURLs: documentURLs, + EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang), } } diff --git a/internal/modules/daily-checklists/module.go b/internal/modules/daily-checklists/module.go index a1455501..31ec505d 100644 --- a/internal/modules/daily-checklists/module.go +++ b/internal/modules/daily-checklists/module.go @@ -22,6 +22,7 @@ type DailyChecklistModule struct{} func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) + emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db) phasesRepo := rPhases.NewPhasesRepository(db) userRepo := rUser.NewUserRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) @@ -30,7 +31,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val panic(fmt.Sprintf("failed to create document service: %v", err)) } - dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc) + dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc) userService := sUser.NewUserService(userRepo, validate) DailyChecklistRoutes(router, userService, dailyChecklistService) diff --git a/internal/modules/daily-checklists/repositories/daily-checklist-empty-kandang.repository.go b/internal/modules/daily-checklists/repositories/daily-checklist-empty-kandang.repository.go new file mode 100644 index 00000000..e969027b --- /dev/null +++ b/internal/modules/daily-checklists/repositories/daily-checklist-empty-kandang.repository.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "errors" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type DailyChecklistEmptyKandangRepository interface { + repository.BaseRepository[entity.DailyChecklistEmptyKandang] + FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) + FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) + FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) + FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) + SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error +} + +type DailyChecklistEmptyKandangRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang] +} + +func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository { + return &DailyChecklistEmptyKandangRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db), + } +} + +func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) { + var rec entity.DailyChecklistEmptyKandang + if err := r.DB().WithContext(ctx). + Where("daily_checklist_id = ?", dailyChecklistID). + First(&rec).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &rec, nil +} + +func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) { + var rec entity.DailyChecklistEmptyKandang + query := r.DB().WithContext(ctx). + Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate) + if excludeDailyChecklistID > 0 { + query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID) + } + if err := query.First(&rec).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &rec, nil +} + +func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) { + var rec entity.DailyChecklistEmptyKandang + if err := r.DB().WithContext(ctx). + Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date). + First(&rec).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &rec, nil +} + +func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) { + if len(kandangIDs) == 0 { + return []entity.DailyChecklistEmptyKandang{}, nil + } + var recs []entity.DailyChecklistEmptyKandang + if err := r.DB().WithContext(ctx). + Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart). + Find(&recs).Error; err != nil { + return nil, err + } + return recs, nil +} + +func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error { + updates := map[string]any{ + "deleted_at": time.Now(), + } + if actorID != nil { + updates["deleted_by"] = *actorID + } + return r.DB().WithContext(ctx). + Model(&entity.DailyChecklistEmptyKandang{}). + Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID). + Updates(updates).Error +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 815aec47..91f43d06 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -44,11 +44,12 @@ type DailyChecklistService interface { } type dailyChecklistService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.DailyChecklistRepository - PhaseRepo phaseRepo.PhasesRepository - DocumentSvc commonSvc.DocumentService + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DailyChecklistRepository + EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository + PhaseRepo phaseRepo.PhasesRepository + DocumentSvc commonSvc.DocumentService } type DailyChecklistDocument struct { @@ -130,20 +131,24 @@ const ( dailyChecklistStatusDraft = "DRAFT" dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date" + dailyChecklistErrEmptyKandangRangeOverlap = "Empty kandang range overlaps with an existing empty kandang period for this kandang" + dailyChecklistErrDateInsideEmptyKandang = "Tanggal berada dalam periode kandang kosong untuk kandang ini" + dailyChecklistErrEmptyKandangEndDateInvalid = "empty_kandang_end_date harus >= date" ) -func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { +func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { return &dailyChecklistService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - PhaseRepo: phaseRepo, - DocumentSvc: documentSvc, + Log: utils.Log, + Validate: validate, + Repository: repo, + EmptyKandangRepo: emptyKandangRepo, + PhaseRepo: phaseRepo, + DocumentSvc: documentSvc, } } func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Kandang") + return db.Preload("Kandang").Preload("EmptyKandang") } func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error { @@ -529,6 +534,23 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) category = dailyChecklistCategoryEmptyKandang } + var emptyEndDate time.Time + if category == dailyChecklistCategoryEmptyKandang { + trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate) + if trimmedEnd == "" { + emptyEndDate = date + } else { + parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd) + if parseErr != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD") + } + if parsedEnd.Before(date) { + return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid) + } + emptyEndDate = parsedEnd + } + } + targetID := uint(0) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { @@ -537,25 +559,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) } if category == dailyChecklistCategoryEmptyKandang { - if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil { + if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil { + return err + } + if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, 0); err != nil { + return err + } + if err := s.validateNoExistingEmptyKandangInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil { return err } if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil { return err } } else { - conflictID := uint(0) - - if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date, category, status, &conflictID); err != nil { + if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil { return err } - if conflictID > 0 { - targetID = conflictID - return nil + if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil { + return err } } - return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) + if err := s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID); err != nil { + return err + } + + if category == dailyChecklistCategoryEmptyKandang { + actorID, _ := m.ActorIDFromContext(c) + if err := s.upsertEmptyKandangRange(tx, targetID, req.KandangId, date, emptyEndDate, actorID); err != nil { + return err + } + } + + return nil }) if err != nil { s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) @@ -585,41 +621,118 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan return nil } -func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { +func (s *dailyChecklistService) validateNoNormalChecklistInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error { + q := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND category <> ? AND deleted_at IS NULL", + kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang) + if excludeDCID > 0 { + q = q.Where("id <> ?", excludeDCID) + } var conflictCount int64 - if err := tx.Model(&entity.DailyChecklist{}). - Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate). - Count(&conflictCount).Error; err != nil { + if err := q.Count(&conflictCount).Error; err != nil { return err } - if conflictCount > 0 { return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist) } - return nil } -func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, newCategory, newStatus string, conflictID *uint) error { - var existing entity.DailyChecklist - if err := tx.Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", - kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). - First(&existing).Error; err != nil { +func (s *dailyChecklistService) validateNoEmptyKandangRangeOverlap(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error { + q := tx.Model(&entity.DailyChecklistEmptyKandang{}). + Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate) + if excludeDCID > 0 { + q = q.Where("daily_checklist_id <> ?", excludeDCID) + } + var overlapCount int64 + if err := q.Count(&overlapCount).Error; err != nil { + return err + } + if overlapCount > 0 { + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap) + } + return nil +} + +func (s *dailyChecklistService) validateDateNotInEmptyKandangRange(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error { + q := tx.Model(&entity.DailyChecklistEmptyKandang{}). + Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date) + if excludeDCID > 0 { + q = q.Where("daily_checklist_id <> ?", excludeDCID) + } + var rec entity.DailyChecklistEmptyKandang + if err := q.First(&rec).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang) +} - if err := tx.Model(&entity.DailyChecklist{}).Where("id = ?", existing.Id).Updates(map[string]interface{}{ - "category": newCategory, - "status": newStatus, - }).Error; err != nil { +func (s *dailyChecklistService) validateNoExistingEmptyKandangInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error { + q := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", + kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang) + if excludeDCID > 0 { + q = q.Where("id <> ?", excludeDCID) + } + var conflictCount int64 + if err := q.Count(&conflictCount).Error; err != nil { + return err + } + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap) + } + return nil +} + +func (s *dailyChecklistService) validateDateNotInExistingEmptyKandangChecklist(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error { + q := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", + kandangID, date, dailyChecklistCategoryEmptyKandang) + if excludeDCID > 0 { + q = q.Where("id <> ?", excludeDCID) + } + var conflictCount int64 + if err := q.Count(&conflictCount).Error; err != nil { + return err + } + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang) + } + return nil +} + +func (s *dailyChecklistService) upsertEmptyKandangRange(tx *gorm.DB, dailyChecklistID, kandangID uint, startDate, endDate time.Time, actorID uint) error { + var existing entity.DailyChecklistEmptyKandang + err := tx.Where("daily_checklist_id = ?", dailyChecklistID).First(&existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } - *conflictID = existing.Id - return nil + if err == nil { + return tx.Model(&entity.DailyChecklistEmptyKandang{}). + Where("id = ?", existing.Id). + Updates(map[string]any{ + "kandang_id": kandangID, + "start_date": startDate, + "end_date": endDate, + "updated_at": time.Now(), + }).Error + } + + record := &entity.DailyChecklistEmptyKandang{ + DailyChecklistId: dailyChecklistID, + KandangId: kandangID, + StartDate: startDate, + EndDate: endDate, + } + if actorID > 0 { + actor := actorID + record.CreatedBy = &actor + } + return tx.Create(record).Error } func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error { @@ -900,11 +1013,53 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create status := req.Status + var emptyEndDate time.Time + if category == dailyChecklistCategoryEmptyKandang { + trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate) + if trimmedEnd == "" { + emptyEndDate = date + } else { + parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd) + if parseErr != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD") + } + if parsedEnd.Before(date) { + return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid) + } + emptyEndDate = parsedEnd + } + } + + var wasBranchC bool // non-empty_kandang → empty_kandang transition err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil { return err } + var existing entity.DailyChecklist + if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + existingIsEmpty := existing.Category == dailyChecklistCategoryEmptyKandang + newIsEmpty := category == dailyChecklistCategoryEmptyKandang + + if newIsEmpty { + if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, id); err != nil { + return err + } + if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, id); err != nil { + return err + } + } else { + if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, id); err != nil { + return err + } + } + var conflictCount int64 if err := tx.Model(&entity.DailyChecklist{}). Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL", @@ -928,12 +1083,63 @@ func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } + + actorID, _ := m.ActorIDFromContext(c) + if newIsEmpty { + if err := s.upsertEmptyKandangRange(tx, id, req.KandangId, date, emptyEndDate, actorID); err != nil { + return err + } + // Branch C: non-empty → empty_kandang, hard-delete task/progress data + if !existingIsEmpty { + wasBranchC = true + if err := tx.Exec(` + DELETE FROM daily_checklist_activity_task_assignments + WHERE task_id IN ( + SELECT id FROM daily_checklist_activity_tasks WHERE checklist_id = ? + )`, id).Error; err != nil { + return err + } + if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil { + return err + } + if err := tx.Where("daily_checklist_id = ?", id).Delete(&entity.DailyChecklistTask{}).Error; err != nil { + return err + } + } + } else if existingIsEmpty { + updates := map[string]any{ + "deleted_at": time.Now(), + } + if actorID > 0 { + updates["deleted_by"] = actorID + } + if err := tx.Model(&entity.DailyChecklistEmptyKandang{}). + Where("daily_checklist_id = ? AND deleted_at IS NULL", id). + Updates(updates).Error; err != nil { + return err + } + } + return nil }) if err != nil { return nil, err } + // Branch C: delete DC documents outside transaction (storage is external) + if wasBranchC && s.DocumentSvc != nil { + docs, docErr := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id)) + if docErr == nil && len(docs) > 0 { + docIDs := make([]uint, 0, len(docs)) + for _, doc := range docs { + docIDs = append(docIDs, doc.Id) + } + if delErr := s.DocumentSvc.DeleteDocuments(c.Context(), docIDs, true); delErr != nil { + s.Log.Errorf("Failed to delete documents for DC %d during empty_kandang conversion: %+v", id, delErr) + } + } + } + return s.GetOne(c, id) } @@ -968,6 +1174,15 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { return gorm.ErrRecordNotFound } + if err := tx.Model(&entity.DailyChecklistEmptyKandang{}). + Where("daily_checklist_id = ? AND deleted_at IS NULL", id). + Updates(map[string]any{ + "deleted_at": time.Now(), + "deleted_by": actorID, + }).Error; err != nil { + return err + } + return nil }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -1695,92 +1910,43 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report } firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC) lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1) - today := time.Now().UTC().Truncate(24 * time.Hour) - type emptyKandangRec struct { + type emptyRangeRec struct { KandangID uint - Date time.Time + StartDate time.Time + EndDate time.Time } - var emptyRecs []emptyKandangRec + var rangeRecs []emptyRangeRec if err := s.Repository.DB().WithContext(c.Context()). - Model(&entity.DailyChecklist{}). - Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL", - kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay). - Select("kandang_id, date"). - Scan(&emptyRecs).Error; err != nil { - s.Log.Errorf("Failed to get empty kandang records for report: %+v", err) + Model(&entity.DailyChecklistEmptyKandang{}). + Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", + kandangIDs, lastDay, firstDay). + Select("kandang_id, start_date, end_date"). + Scan(&rangeRecs).Error; err != nil { + s.Log.Errorf("Failed to get empty kandang ranges for report: %+v", err) return err } emptyDaysByKandang := make(map[uint]map[int]struct{}) - if len(emptyRecs) > 0 { - minEmptyDate := emptyRecs[0].Date - for _, rec := range emptyRecs[1:] { - if rec.Date.Before(minEmptyDate) { - minEmptyDate = rec.Date - } + for _, rec := range rangeRecs { + effectiveStart := rec.StartDate + if effectiveStart.Before(firstDay) { + effectiveStart = firstDay + } + effectiveEnd := rec.EndDate + if effectiveEnd.After(lastDay) { + effectiveEnd = lastDay + } + if effectiveStart.After(effectiveEnd) { + continue } - type checklistDateRec struct { - KandangID uint - Date time.Time + if _, ok := emptyDaysByKandang[rec.KandangID]; !ok { + emptyDaysByKandang[rec.KandangID] = make(map[int]struct{}) } - var nextDates []checklistDateRec - if err := s.Repository.DB().WithContext(c.Context()). - Model(&entity.DailyChecklist{}). - Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL", - kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected). - Select("kandang_id, date"). - Order("kandang_id ASC, date ASC"). - Scan(&nextDates).Error; err != nil { - s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err) - return err - } - - nextDatesByKandang := make(map[uint][]time.Time) - for _, row := range nextDates { - nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date) - } - - for _, rec := range emptyRecs { - var nextDate time.Time - for _, d := range nextDatesByKandang[rec.KandangID] { - if d.After(rec.Date) { - nextDate = d - break - } - } - - // If no next checklist, cap empty period at today (not end of month) - ceiling := lastDay - if today.Before(lastDay) { - ceiling = today - } - periodEnd := ceiling - if !nextDate.IsZero() { - periodEnd = nextDate.AddDate(0, 0, -1) - } - - effectiveStart := rec.Date - if effectiveStart.Before(firstDay) { - effectiveStart = firstDay - } - effectiveEnd := periodEnd - if effectiveEnd.After(lastDay) { - effectiveEnd = lastDay - } - - if effectiveStart.After(effectiveEnd) { - continue - } - - if _, ok := emptyDaysByKandang[rec.KandangID]; !ok { - emptyDaysByKandang[rec.KandangID] = make(map[int]struct{}) - } - for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) { - emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} - } + for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) { + emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} } } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service_test.go b/internal/modules/daily-checklists/services/daily-checklist.service_test.go index 4b3a16c2..9d001947 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service_test.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service_test.go @@ -208,8 +208,18 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t * Count(&activeInRange).Error; err != nil { t.Fatalf("failed counting checklists in range: %v", err) } - if activeInRange != 5 { - t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange) + if activeInRange != 1 { + t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange) + } + + var emptyRangeCount int64 + if err := db.Model(&entity.DailyChecklistEmptyKandang{}). + Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")). + Count(&emptyRangeCount).Error; err != nil { + t.Fatalf("failed counting empty kandang ranges: %v", err) + } + if emptyRangeCount != 1 { + t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount) } } @@ -304,6 +314,18 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm. updated_at DATETIME NULL, deleted_at DATETIME NULL )`, + `CREATE TABLE daily_checklist_empty_kandangs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + daily_checklist_id INTEGER NOT NULL, + kandang_id INTEGER NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + created_by INTEGER NULL, + deleted_by INTEGER NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )`, `INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, @@ -316,7 +338,8 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm. } repo := repository.NewDailyChecklistRepository(db) - svc := NewDailyChecklistService(repo, nil, validator.New(), nil) + emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db) + svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil) return svc, db } diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index fdf19dbe..3b70978f 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -9,7 +9,8 @@ type Create struct { KandangId uint `json:"kandang_id" validate:"required"` Category string `json:"category" validate:"required"` Status string `json:"status" validate:"required"` - EmptyKandang bool `json:"empty_kandang"` + EmptyKandang bool `json:"empty_kandang"` + EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"` } type Update struct { diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index fddd4356..c7258c3d 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -65,6 +65,8 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error { RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")), ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)), ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)), + SortBy: strings.TrimSpace(c.Query("sort_by", "")), + SortOrder: strings.TrimSpace(c.Query("sort_order", "")), } if isAllExpenseExcelExportRequest(c) { diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index d41c5dd7..e15f3247 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -177,10 +177,48 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context return nil, 0, err } + sortExpr := "expense_realizations.created_at" + order := "DESC" + if filters.SortOrder == "asc" { + order = "ASC" + } + switch filters.SortBy { + case "po_number": + sortExpr = "expenses.po_number" + case "reference_number": + sortExpr = "expenses.reference_number" + case "realization_date": + sortExpr = "expenses.realization_date" + case "transaction_date": + sortExpr = "expenses.transaction_date" + case "category": + sortExpr = "expenses.category" + case "product": + sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)" + case "supplier": + sortExpr = "suppliers.name" + case "location": + sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)" + case "kandang": + sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)" + case "qty_pengajuan": + sortExpr = "expense_nonstocks.qty" + case "price_pengajuan": + sortExpr = "expense_nonstocks.price" + case "total_pengajuan": + sortExpr = "expense_nonstocks.qty * expense_nonstocks.price" + case "qty_realisasi": + sortExpr = "expense_realizations.qty" + case "price_realisasi": + sortExpr = "expense_realizations.price" + case "total_realisasi": + sortExpr = "expense_realizations.qty * expense_realizations.price" + } + if err := db. Offset(offset). Limit(limit). - Order("expense_realizations.created_at DESC"). + Order(sortExpr + " " + order). Find(&realizations).Error; err != nil { return nil, 0, err } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 48a18042..37806d44 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -289,7 +289,40 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens like, ) } - return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC") + sortBy := strings.TrimSpace(params.SortBy) + sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder)) + if sortOrder == "" { + sortOrder = "DESC" + } + + switch sortBy { + case "reference_number": + return db.Order("expenses.reference_number " + sortOrder) + case "transaction_date": + return db.Order("expenses.transaction_date " + sortOrder) + case "realization_date": + return db.Order("expenses.realization_date " + sortOrder) + case "location": + return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder) + case "created_user": + return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder) + case "supplier": + return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder) + case "grand_total": + return db.Order(`(SELECT COALESCE( + (SELECT SUM(er.qty * er.price) FROM expense_realizations er + JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id + WHERE en.expense_id = expenses.id), + (SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2 + WHERE en2.expense_id = expenses.id), + 0)) ` + sortOrder) + case "is_paid": + return db.Order("expenses.is_paid " + sortOrder) + case "created_at": + return db.Order("expenses.created_at " + sortOrder) + default: + return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC") + } }) if scopeErr != nil { diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 0046ce7f..9311e42d 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -54,6 +54,8 @@ type Query struct { RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"` ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"` } type CreateRealization struct { diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go index 228feeaa..200f7c54 100644 --- a/internal/modules/finance/transactions/controllers/transaction.controller.go +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -97,6 +97,8 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error { CustomerIDs: customerIDs, SupplierIDs: supplierIDs, SortDate: c.Query("sort_date", ""), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), StartDate: c.Query("start_date", ""), EndDate: c.Query("end_date", ""), } diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go index b5797cb4..d58c4aa3 100644 --- a/internal/modules/finance/transactions/services/transaction.service.go +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -72,19 +72,26 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - if params.Search != "" { - like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + needsPartyJoin := params.Search != "" || params.SortBy == "customer_name" + needsBankJoin := params.Search != "" || params.SortBy == "bank" + + if needsPartyJoin { db = db.Joins( "LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL", string(utils.PaymentPartyCustomer), ).Joins( "LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL", string(utils.PaymentPartySupplier), - ).Joins( - "LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL", ) + } + if needsBankJoin { + db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL") + } + + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" db = db.Where( - `LOWER(payment_code) LIKE ? OR + `(LOWER(payment_code) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(payment_method, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR @@ -93,7 +100,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en LOWER(COALESCE(suppliers.name, '')) LIKE ? OR LOWER(COALESCE(banks.name, '')) LIKE ? OR CAST(payments.nominal AS TEXT) LIKE ? OR - TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`, + TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`, like, like, like, like, like, like, like, like, like, like, ) } @@ -138,7 +145,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en db = db.Where("payment_date < ?", *endDate) } - return applyTransactionSort(db, params.SortDate) + return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate) }) if err != nil { @@ -270,13 +277,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim return startPtr, endPtr, nil } -func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB { +func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB { + order := "DESC" + if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" { + order = "ASC" + } + + switch strings.ToLower(strings.TrimSpace(sortBy)) { + case "payment_code": + return db.Order("payments.payment_code " + order) + case "reference_number": + return db.Order("payments.reference_number " + order) + case "transaction_type": + return db.Order("payments.transaction_type " + order) + case "customer_name": + return db.Order("COALESCE(customers.name, suppliers.name) " + order) + case "payment_date": + return db.Order("payments.payment_date " + order) + case "created_at": + return db.Order("payments.created_at " + order) + case "payment_method": + return db.Order("payments.payment_method " + order) + case "bank": + return db.Order("banks.account_number " + order) + case "expense_amount": + return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order) + case "income_amount": + return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order) + } + switch strings.ToLower(strings.TrimSpace(sortDate)) { case "created_at": - return db.Order("created_at DESC").Order("payment_date DESC") - case "payment_date": - return db.Order("payment_date DESC").Order("created_at DESC") + return db.Order("payments.created_at DESC").Order("payments.payment_date DESC") default: - return db.Order("payment_date DESC").Order("created_at DESC") + return db.Order("payments.payment_date DESC").Order("payments.created_at DESC") } } diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go index 7a71cb51..4b6d7c5a 100644 --- a/internal/modules/finance/transactions/validations/transaction.validation.go +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -17,6 +17,8 @@ type Query struct { CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"` SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index aa599894..d78153a0 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -310,6 +310,8 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir) case "grand_total": return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir) + case "created_at": + return db.Order("marketings.created_at " + orderDir) default: return db.Order("created_at DESC").Order("updated_at DESC") } @@ -540,9 +542,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil, err } + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) @@ -628,6 +636,23 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO } } + if latestApproval != nil && latestApproval.StepNumber == uint16(utils.MarketingDeliveryOrder) { + action := entity.ApprovalActionUpdated + _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + id, + utils.MarketingStepSalesOrder, + &action, + actorID, + nil) + if err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval to Sales Order") + } + } + } + return nil }) if err != nil { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 5f646112..9813c5dc 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -516,7 +516,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u c.Context(), utils.ApprovalWorkflowMarketing, id, - approvalutils.ApprovalStep(latestApproval.StepNumber), + utils.MarketingStepPengajuan, &action, actorID, nil) @@ -770,15 +770,21 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { - totalWeight, totalPrice := s.calculatePriceByMarketingType( - marketingType, - rp.Qty, - rp.AvgWeight, - rp.UnitPrice, - rp.Week, - rp.ConvertionUnit, - rp.WeightPerConvertion, - ) + var totalWeight, totalPrice float64 + if rp.TotalPrice != nil { + totalWeight = math.Round(rp.Qty*rp.AvgWeight*100) / 100 + totalPrice = *rp.TotalPrice + } else { + totalWeight, totalPrice = s.calculatePriceByMarketingType( + marketingType, + rp.Qty, + rp.AvgWeight, + rp.UnitPrice, + rp.Week, + rp.ConvertionUnit, + rp.WeightPerConvertion, + ) + } marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, @@ -821,7 +827,7 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, totalPrice = math.Round(qty*unitPrice*100) / 100 } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { totalWeight = math.Round(qty*avgWeight*100) / 100 - totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100 + totalPrice = math.Round(totalWeight*unitPrice*100) / 100 } else { totalWeight = math.Round(qty*avgWeight*100) / 100 diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index dba96eeb..629a5df6 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -31,7 +31,7 @@ type DeliveryOrderQuery struct { MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index f3fda360..06c0afce 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -26,6 +26,7 @@ type CreateMarketingProduct struct { UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"` + TotalPrice *float64 `json:"total_price" validate:"omitempty,gt=0"` } type Update struct { diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index eceafa39..bc904807 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -14,6 +14,7 @@ type CustomerRelationDTO struct { Name string `json:"name"` Type string `json:"type"` AccountNumber string `json:"account_number"` + BankName string `json:"bank_name"` Address string `json:"address,omitempty"` Balance float64 `json:"balance"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` @@ -28,6 +29,7 @@ type CustomerListDTO struct { Phone string `json:"phone"` Email string `json:"email"` AccountNumber string `json:"account_number"` + BankName string `json:"bank_name"` Balance float64 `json:"balance"` Pic userDTO.UserRelationDTO `json:"pic"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` @@ -53,6 +55,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO { Name: e.Name, Type: e.Type, AccountNumber: e.AccountNumber, + BankName: e.BankName, Address: e.Address, Balance: e.Balance, Pic: pic, @@ -81,6 +84,7 @@ func ToCustomerListDTO(e entity.Customer) CustomerListDTO { Phone: e.Phone, Email: e.Email, AccountNumber: e.AccountNumber, + BankName: e.BankName, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index 416d65b3..18178dd7 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -133,6 +133,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Phone: req.Phone, Email: req.Email, AccountNumber: req.AccountNumber, + BankName: req.BankName, CreatedBy: actorID, } @@ -193,6 +194,10 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["account_number"] = *req.AccountNumber } + if req.BankName != nil { + updateBody["bank_name"] = *req.BankName + } + if len(updateBody) == 0 { return s.GetOne(c, id) } diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index 5b814b37..2ca1b9ea 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -8,6 +8,7 @@ type Create struct { Phone string `json:"phone" validate:"required_strict,max=20"` Email string `json:"email" validate:"required_strict,email,max=50"` AccountNumber string `json:"account_number" validate:"required_strict,max=50"` + BankName string `json:"bank_name" validate:"required_strict,max=100"` } type Update struct { @@ -18,6 +19,7 @@ type Update struct { Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` Email *string `json:"email,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` + BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"` } type Query struct { diff --git a/internal/modules/master/suppliers/dto/supplier.dto.go b/internal/modules/master/suppliers/dto/supplier.dto.go index c8302b0b..49f3c824 100644 --- a/internal/modules/master/suppliers/dto/supplier.dto.go +++ b/internal/modules/master/suppliers/dto/supplier.dto.go @@ -26,6 +26,7 @@ type SupplierListDTO struct { Address string `json:"address"` Npwp *string `json:"npwp,omitempty"` AccountNumber *string `json:"account_number,omitempty"` + BankName *string `json:"bank_name,omitempty"` Balance float64 `json:"balance"` DueDate int `json:"due_date"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` @@ -66,6 +67,7 @@ func ToSupplierListDTO(e entity.Supplier) SupplierListDTO { Address: e.Address, Npwp: e.Npwp, AccountNumber: e.AccountNumber, + BankName: e.BankName, Balance: e.Balance, DueDate: e.DueDate, SupplierRelationDTO: ToSupplierRelationDTO(e), diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index d211ed9d..bfa50f84 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -160,6 +160,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Address: req.Address, Npwp: req.Npwp, AccountNumber: req.AccountNumber, + BankName: req.BankName, DueDate: req.DueDate, CreatedBy: actorID, } @@ -243,6 +244,10 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["account_number"] = *req.AccountNumber } + if req.BankName != nil { + updateBody["bank_name"] = *req.BankName + } + if req.DueDate != nil { updateBody["due_date"] = *req.DueDate } diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go index 720e784e..f0eb1b21 100644 --- a/internal/modules/master/suppliers/validations/supplier.validation.go +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -12,6 +12,7 @@ type Create struct { Address string `json:"address" validate:"required_strict"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` + BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"` DueDate int `json:"due_date" validate:"required_strict,number,gt=0"` } @@ -27,6 +28,7 @@ type Update struct { Address *string `json:"address,omitempty" validate:"omitempty"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` + BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"` DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"` } diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index e3bce86f..9906523e 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -102,6 +102,8 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query { ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), + SortBy: strings.TrimSpace(c.Query("sort_by", "")), + SortOrder: strings.TrimSpace(c.Query("sort_order", "")), } } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 61285a6c..a0a5284b 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -261,7 +261,48 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = applyPurchaseApprovalStatusFilter(db, approvalStatuses) db = applyPurchaseSearchFilter(db, search) - return db.Order("created_at DESC").Order("purchases.id DESC") + sortBy := strings.TrimSpace(params.SortBy) + sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder)) + if sortOrder == "" { + sortOrder = "DESC" + } + + switch sortBy { + case "po_expedition": + return db.Order(`(SELECT MIN(e.reference_number) FROM purchase_items pi + LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id + LEFT JOIN expenses e ON e.id = en.expense_id + WHERE pi.purchase_id = purchases.id) ` + sortOrder + " NULLS LAST") + case "supplier": + return db.Order(`(SELECT COALESCE(s.name, '') FROM suppliers s WHERE s.id = purchases.supplier_id) ` + sortOrder) + case "requester_name": + return db.Order(`(SELECT COALESCE(u.name, '') FROM users u WHERE u.id = purchases.created_by) ` + sortOrder) + case "products": + return db.Order(`(SELECT MIN(COALESCE(p.name, '')) FROM purchase_items pi + JOIN products p ON p.id = pi.product_id + WHERE pi.purchase_id = purchases.id) ` + sortOrder) + case "location": + return db.Order(`(SELECT MIN(COALESCE(l.name, '')) FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + JOIN locations l ON l.id = w.location_id + WHERE pi.purchase_id = purchases.id) ` + sortOrder) + case "po_date": + return db.Order("purchases.po_date " + sortOrder) + case "po_number": + return db.Order("COALESCE(purchases.po_number, purchases.pr_number) " + sortOrder) + case "received_date": + return db.Order(`(SELECT MIN(pi2.received_date) FROM purchase_items pi2 WHERE pi2.purchase_id = purchases.id) ` + sortOrder) + case "due_date": + return db.Order("purchases.due_date " + sortOrder) + case "status": + return db.Order(`(SELECT COALESCE(a.step_name, '') FROM approvals a + WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = purchases.id + ORDER BY a.action_at DESC, a.id DESC LIMIT 1) ` + sortOrder) + case "created_at": + return db.Order("purchases.created_at " + sortOrder) + default: + return db.Order("created_at DESC").Order("purchases.id DESC") + } }) if err != nil { diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index cc467b8f..1f49eaca 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -81,4 +81,6 @@ type Query struct { 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"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"` } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index b13260ea..cc505ee8 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -51,6 +51,8 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { AreaId: int64(ctx.QueryInt("area_id", 0)), LocationId: int64(ctx.QueryInt("location_id", 0)), RealizationDate: ctx.Query("realization_date", ""), + SortBy: ctx.Query("sort_by", ""), + SortOrder: ctx.Query("sort_order", ""), } locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) @@ -362,6 +364,7 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error { StartDate: ctx.Query("start_date", ""), EndDate: ctx.Query("end_date", ""), FilterBy: ctx.Query("filter_by", ""), + SortBy: ctx.Query("sort_by", ""), SortOrder: ctx.Query("sort_order", ""), } @@ -459,6 +462,8 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { Limit: ctx.QueryInt("limit", 10), CustomerIDs: customerIDs, FilterBy: strings.ToUpper(ctx.Query("filter_by", "")), + SortBy: ctx.Query("sort_by", ""), + SortOrder: ctx.Query("sort_order", ""), StartDate: ctx.Query("start_date", ""), EndDate: ctx.Query("end_date", ""), } diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 9004882b..c3e85b45 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -2,7 +2,7 @@ package repositories import ( "context" - + "strings" "time" "gorm.io/gorm" @@ -30,7 +30,7 @@ type CustomerPaymentTransaction struct { type CustomerPaymentRepository interface { GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) - GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) + GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint, sortBy, sortOrder string) ([]uint, int64, error) } type customerPaymentRepositoryImpl struct { @@ -146,21 +146,34 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context. return result.Nominal, nil } -func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) { - subQuery := r.db.WithContext(ctx). - Table("(" + - "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + - "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + - "INNER JOIN marketings m ON m.id = mp.marketing_id " + - "INNER JOIN customers c ON c.id = m.customer_id " + - "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + - "UNION " + - "SELECT DISTINCT c.id as customer_id FROM payments p " + - "INNER JOIN customers c ON c.id = p.party_id " + - "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + - "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + - ") as customer_ids") +func resolveCustomerPaymentSortClause(sortBy, sortOrder string) string { + direction := "ASC" + if strings.EqualFold(strings.TrimSpace(sortOrder), "desc") { + direction = "DESC" + } + switch strings.ToLower(strings.TrimSpace(sortBy)) { + case "customer": + return "customer_name " + direction + default: + return "customer_name ASC" + } +} +func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint, sortBy, sortOrder string) ([]uint, int64, error) { + unionSQL := "(" + + "SELECT DISTINCT c.id as customer_id, c.name as customer_name FROM marketing_delivery_products mdp " + + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + + "INNER JOIN marketings m ON m.id = mp.marketing_id " + + "INNER JOIN customers c ON c.id = m.customer_id " + + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "UNION " + + "SELECT DISTINCT c.id as customer_id, c.name as customer_name FROM payments p " + + "INNER JOIN customers c ON c.id = p.party_id " + + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + + ") as customer_ids" + + subQuery := r.db.WithContext(ctx).Table(unionSQL) if len(allowedCustomerIDs) > 0 { subQuery = subQuery.Where("customer_id IN ?", allowedCustomerIDs) } @@ -170,28 +183,14 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte return nil, 0, err } - var customerIDs []uint - query := r.db.WithContext(ctx). - Table("(" + - "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + - "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + - "INNER JOIN marketings m ON m.id = mp.marketing_id " + - "INNER JOIN customers c ON c.id = m.customer_id " + - "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + - "UNION " + - "SELECT DISTINCT c.id as customer_id FROM payments p " + - "INNER JOIN customers c ON c.id = p.party_id " + - "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + - "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + - ") as customer_ids"). - Select("customer_id") - + query := r.db.WithContext(ctx).Table(unionSQL).Select("customer_id") if len(allowedCustomerIDs) > 0 { query = query.Where("customer_id IN ?", allowedCustomerIDs) } + var customerIDs []uint err := query. - Order("customer_id ASC"). + Order(resolveCustomerPaymentSortClause(sortBy, sortOrder)). Limit(limit). Offset(offset). Pluck("customer_id", &customerIDs). diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index fefcbade..c5db5e09 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -52,6 +52,19 @@ func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) ) } +func resolveDebtSupplierSortClause(filters *validation.DebtSupplierQuery) string { + direction := "ASC" + if strings.EqualFold(strings.TrimSpace(filters.SortOrder), "desc") { + direction = "DESC" + } + switch strings.ToLower(strings.TrimSpace(filters.SortBy)) { + case "supplier": + return "suppliers.name " + direction + default: + return "suppliers.name ASC" + } +} + func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { case "po_date": @@ -129,15 +142,24 @@ func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Conte offset = 0 } - var supplierIDs []uint - if err := query. - Select("suppliers.id"). - Order("suppliers.id ASC"). + type supplierIDResult struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` + } + var idResults []supplierIDResult + if err := r.baseSupplierQuery(ctx, filters). + Select("suppliers.id, suppliers.name"). + Group("suppliers.id, suppliers.name"). + Order(resolveDebtSupplierSortClause(filters)). Offset(offset). Limit(limit). - Pluck("suppliers.id", &supplierIDs).Error; err != nil { + Scan(&idResults).Error; err != nil { return nil, 0, err } + supplierIDs := make([]uint, 0, len(idResults)) + for _, r := range idResults { + supplierIDs = append(supplierIDs, r.ID) + } if len(supplierIDs) == 0 { return []entity.Supplier{}, totalSuppliers, nil @@ -146,6 +168,7 @@ func (r *debtSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Conte var suppliers []entity.Supplier if err := r.db.WithContext(ctx). Where("id IN ?", supplierIDs). + Order(resolveDebtSupplierSortClause(filters)). Find(&suppliers).Error; err != nil { return nil, 0, err } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 54af581d..3e203c65 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1029,6 +1029,13 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. } func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) { + if params.SortBy == "" { + params.SortBy = "customer" + } + if params.SortOrder == "" { + params.SortOrder = "asc" + } + if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -1083,7 +1090,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C offset := (page - 1) * limit var err error - customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs) + customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs, params.SortBy, params.SortOrder) if err != nil { return nil, 0, err } @@ -1755,6 +1762,12 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu if params.FilterBy == "" { params.FilterBy = "received_date" } + if params.SortBy == "" { + params.SortBy = "supplier" + } + if params.SortOrder == "" { + params.SortOrder = "asc" + } if err := s.Validate.Struct(params); err != nil { return nil, 0, err diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 8b72152f..0ef458e1 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -13,6 +13,8 @@ type ExpenseQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=po_number reference_number realization_date transaction_date category product supplier location kandang qty_pengajuan price_pengajuan total_pengajuan qty_realisasi price_realisasi total_realisasi"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` AllowedAreaIDs []int64 `query:"-"` AllowedLocationIDs []int64 `query:"-"` } @@ -58,6 +60,7 @@ type DebtSupplierQuery struct { StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=supplier"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` AllowedAreaIDs []int64 `query:"-"` AllowedLocationIDs []int64 `query:"-"` @@ -108,6 +111,8 @@ type CustomerPaymentQuery struct { Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=TRANS_DATE REALIZATION_DATE"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=customer"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` }