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/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/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..24aeb471 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,52 @@ 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 + } + } + 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,6 +1082,26 @@ 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 + } + } 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 { @@ -968,6 +1142,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 +1878,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 {