diff --git a/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql new file mode 100644 index 00000000..ce49c3f2 --- /dev/null +++ b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.down.sql @@ -0,0 +1,21 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected; +DROP INDEX IF EXISTS idx_daily_checklists_deleted_at; +DROP INDEX IF EXISTS idx_daily_checklists_deleted_by; + +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by; + +ALTER TABLE daily_checklists + DROP COLUMN IF EXISTS deleted_at, + DROP COLUMN IF EXISTS deleted_by; + +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected + ON daily_checklists (date, kandang_id, category) + WHERE (status IS NULL OR status <> 'REJECTED'); + +COMMIT; diff --git a/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql new file mode 100644 index 00000000..d3f000f0 --- /dev/null +++ b/internal/database/migrations/20260422091116_add_soft_delete_to_daily_checklists.up.sql @@ -0,0 +1,27 @@ +BEGIN; + +ALTER TABLE daily_checklists + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS deleted_by BIGINT; + +CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_at + ON daily_checklists (deleted_at); +CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_by + ON daily_checklists (deleted_by); + +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by, + ADD CONSTRAINT fk_daily_checklists_deleted_by + FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; + +DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected + ON daily_checklists (date, kandang_id, category) + WHERE (status IS NULL OR status <> 'REJECTED') + AND deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql new file mode 100644 index 00000000..07702864 --- /dev/null +++ b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.down.sql @@ -0,0 +1,41 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM daily_checklists + WHERE category::text = 'empty_kandang' + ) THEN + RAISE EXCEPTION 'Cannot rollback category_code enum: daily_checklists still contains empty_kandang'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM phases + WHERE category::text = 'empty_kandang' + ) THEN + RAISE EXCEPTION 'Cannot rollback category_code enum: phases still contains empty_kandang'; + END IF; +END $$; + +ALTER TYPE category_code RENAME TO category_code_old; + +CREATE TYPE category_code AS ENUM ( + 'pullet_open', + 'pullet_close', + 'produksi_open', + 'produksi_close' +); + +ALTER TABLE phases + ALTER COLUMN category TYPE category_code + USING category::text::category_code; + +ALTER TABLE daily_checklists + ALTER COLUMN category TYPE category_code + USING category::text::category_code; + +DROP TYPE category_code_old; + +COMMIT; diff --git a/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql new file mode 100644 index 00000000..34c94833 --- /dev/null +++ b/internal/database/migrations/20260422091206_add_empty_kandang_to_category_code.up.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = 'category_code' + AND e.enumlabel = 'empty_kandang' + ) THEN + ALTER TYPE category_code ADD VALUE 'empty_kandang'; + END IF; +END $$; diff --git a/internal/entities/daily-checklist.go b/internal/entities/daily-checklist.go index 71513259..6c2106ae 100644 --- a/internal/entities/daily-checklist.go +++ b/internal/entities/daily-checklist.go @@ -1,6 +1,10 @@ package entities -import "time" +import ( + "time" + + "gorm.io/gorm" +) type DailyChecklist struct { Id uint `gorm:"primaryKey"` @@ -14,12 +18,15 @@ type DailyChecklist struct { DocumentPath *string RejectReason *string CreatedBy *uint - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedBy *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + 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"` } diff --git a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go index 8d664d31..1e232401 100644 --- a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go +++ b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go @@ -40,7 +40,8 @@ func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN areas a ON a.id = loc.area_id"). - Where("dc.id IN ?", ids) + Where("dc.id IN ?", ids). + Where("dc.deleted_at IS NULL") db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") if err != nil { diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 5fc8c0bc..3ae7de26 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct { Baik int } +const ( + dailyChecklistDateLayout = "2006-01-02" + dailyChecklistCategoryEmptyKandang = "empty_kandang" + dailyChecklistStatusRejected = "REJECTED" + dailyChecklistStatusDraft = "DRAFT" +) + func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { return &dailyChecklistService{ Log: utils.Log, @@ -146,7 +153,8 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN areas a ON a.id = loc.area_id"). - Where("dc.id = ?", checklistID) + Where("dc.id = ?", checklistID). + Where("dc.deleted_at IS NULL") scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") if err != nil { @@ -196,7 +204,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error db := s.Repository.DB().WithContext(c.Context()). Table("daily_checklist_activity_tasks t"). - Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id"). + Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id AND dc.deleted_at IS NULL"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN areas a ON a.id = loc.area_id"). @@ -228,7 +236,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([ Table("daily_checklists dc"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). - Joins("JOIN areas a ON a.id = loc.area_id") + Joins("JOIN areas a ON a.id = loc.area_id"). + Where("dc.deleted_at IS NULL") var scopeErr error db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") @@ -501,66 +510,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } - date, err := time.Parse("2006-01-02", req.Date) + date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date)) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") } status := req.Status category := req.Category + endDate := date + + if req.EmptyKandang { + if strings.TrimSpace(req.EmptyKandangEndDate) == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true") + } + + endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD") + } + if endDate.Before(date) { + return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date") + } + + category = dailyChecklistCategoryEmptyKandang + } + targetID := uint(0) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - existing := new(entity.DailyChecklist) - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED"). - Take(existing).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err + if req.EmptyKandang { + return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID) } - if err == nil { - if err := tx.Model(&entity.DailyChecklist{}). - Where("id = ?", existing.Id). - Update("updated_at", time.Now()).Error; err != nil { - return err - } - - targetID = existing.Id - return nil - } - - createStatus := status - var rejectedCount int64 - if err := tx.Model(&entity.DailyChecklist{}). - Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED"). - Count(&rejectedCount).Error; err != nil { - return err - } - if rejectedCount > 0 { - createStatus = "DRAFT" - } - - createBody := &entity.DailyChecklist{ - KandangId: req.KandangId, - Date: date, - Category: category, - Status: &createStatus, - } - - if err := tx.Create(createBody).Error; err != nil { - // Handle concurrent insert for active checklist with same key. - if findErr := tx. - Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED"). - Take(existing).Error; findErr == nil { - targetID = existing.Id - return nil - } - return err - } - - targetID = createBody.Id - return nil + return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) }) if err != nil { s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) @@ -570,6 +552,109 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.GetOne(c, targetID) } +func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error { + existing := new(entity.DailyChecklist) + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected). + Take(existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + if err == nil { + if err := tx.Model(&entity.DailyChecklist{}). + Where("id = ?", existing.Id). + Update("updated_at", time.Now()).Error; err != nil { + return err + } + + *targetID = existing.Id + return nil + } + + createStatus := status + var rejectedCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected). + Count(&rejectedCount).Error; err != nil { + return err + } + if rejectedCount > 0 { + createStatus = dailyChecklistStatusDraft + } + + createBody := &entity.DailyChecklist{ + KandangId: kandangID, + Date: date, + Category: category, + Status: &createStatus, + } + + if err := tx.Create(createBody).Error; err != nil { + // Handle concurrent insert for active checklist with same key. + if findErr := tx. + Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", date, kandangID, category, dailyChecklistStatusRejected). + Take(existing).Error; findErr == nil { + *targetID = existing.Id + return nil + } + return err + } + + *targetID = createBody.Id + return nil +} + +func (s *dailyChecklistService) createBulkDailyChecklists(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, category, status string, targetID *uint) error { + var conflictCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND category = ? AND date BETWEEN ? AND ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", kandangID, category, startDate, endDate, dailyChecklistStatusRejected). + Count(&conflictCount).Error; err != nil { + return err + } + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range") + } + + for currentDate := startDate; !currentDate.After(endDate); currentDate = currentDate.AddDate(0, 0, 1) { + createStatus := status + var rejectedCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("date = ? AND kandang_id = ? AND category = ? AND status = ? AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected). + Count(&rejectedCount).Error; err != nil { + return err + } + if rejectedCount > 0 { + createStatus = dailyChecklistStatusDraft + } + + createBody := &entity.DailyChecklist{ + KandangId: kandangID, + Date: currentDate, + Category: category, + Status: &createStatus, + } + + if err := tx.Create(createBody).Error; err != nil { + // Handle concurrent insert for active checklist in same date range. + var existingActiveCount int64 + checkErr := tx.Model(&entity.DailyChecklist{}). + Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?) AND deleted_at IS NULL", currentDate, kandangID, category, dailyChecklistStatusRejected). + Count(&existingActiveCount).Error + if checkErr == nil && existingActiveCount > 0 { + return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists for at least one date in range") + } + return err + } + + if currentDate.Equal(startDate) { + *targetID = createBody.Id + } + } + + return nil +} + func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -712,7 +797,35 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.ensureChecklistAccess(c, id); err != nil { return err } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + updateResult := tx.Model(&entity.DailyChecklist{}). + Where("id = ?", id). + Updates(map[string]any{ + "deleted_by": actorID, + "updated_at": time.Now(), + }) + if updateResult.Error != nil { + return updateResult.Error + } + if updateResult.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + deleteResult := tx.Delete(&entity.DailyChecklist{}, id) + if deleteResult.Error != nil { + return deleteResult.Error + } + if deleteResult.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil + }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") } @@ -1152,7 +1265,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left, MAX(a.updated_at) AS last_activity`). Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). - Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). + Joins("JOIN daily_checklists d ON d.id = t.checklist_id AND d.deleted_at IS NULL"). Joins("JOIN kandang_groups k ON k.id = d.kandang_id"). Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). @@ -1224,7 +1337,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report db := s.Repository.DB().WithContext(c.Context()). Table("daily_checklist_activity_task_assignments AS dca"). Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id"). - Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id"). + Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id AND dc.deleted_at IS NULL"). Joins("JOIN employees e ON e.id = dca.employee_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN locations loc ON loc.id = k.location_id"). diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index e3a5484c..3738a58d 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -5,10 +5,12 @@ import ( ) type Create struct { - Date string `json:"date" validate:"required"` - KandangId uint `json:"kandang_id" validate:"required"` - Category string `json:"category" validate:"required"` - Status string `json:"status" validate:"required"` + Date string `json:"date" validate:"required"` + KandangId uint `json:"kandang_id" validate:"required"` + Category string `json:"category" validate:"required"` + Status string `json:"status" validate:"required"` + EmptyKandang bool `json:"empty_kandang"` + EmptyKandangEndDate string `json:"empty_kandang_end_date"` } type Update struct {