adjust softdelete daily checklist; add empty kandang

This commit is contained in:
giovanni
2026-04-22 16:24:31 +07:00
parent 2a141a96d1
commit 91d51bf1b8
8 changed files with 287 additions and 63 deletions
@@ -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;
@@ -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;
@@ -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;
@@ -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 $$;
+10 -3
View File
@@ -1,6 +1,10 @@
package entities package entities
import "time" import (
"time"
"gorm.io/gorm"
)
type DailyChecklist struct { type DailyChecklist struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
@@ -14,12 +18,15 @@ type DailyChecklist struct {
DocumentPath *string DocumentPath *string
RejectReason *string RejectReason *string
CreatedBy *uint CreatedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"` DeletedBy *uint
UpdatedAt time.Time `gorm:"autoUpdateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
@@ -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 kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_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.id IN ?", ids) Where("dc.id IN ?", ids).
Where("dc.deleted_at IS NULL")
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil { if err != nil {
@@ -122,6 +122,13 @@ type DailyChecklistReportCategory struct {
Baik int 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 { func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{ return &dailyChecklistService{
Log: utils.Log, 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 kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_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.id = ?", checklistID) Where("dc.id = ?", checklistID).
Where("dc.deleted_at IS NULL")
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil { if err != nil {
@@ -196,7 +204,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t"). 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 kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_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").
@@ -228,7 +236,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
Table("daily_checklists dc"). Table("daily_checklists dc").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_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 var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id") 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 return nil, err
} }
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
} }
status := req.Status status := req.Status
category := req.Category 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) targetID := uint(0)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
existing := new(entity.DailyChecklist) if req.EmptyKandang {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
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 err == nil { return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
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
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) 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) 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) { func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err 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 { if err := s.ensureChecklistAccess(c, id); err != nil {
return err 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") 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, SUM(CASE WHEN NOT a.checked THEN 1 ELSE 0 END) AS activity_left,
MAX(a.updated_at) AS last_activity`). MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). 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 kandang_groups k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_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()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_task_assignments AS dca"). Table("daily_checklist_activity_task_assignments AS dca").
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id"). 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 employees e ON e.id = dca.employee_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id"). Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
@@ -5,10 +5,12 @@ import (
) )
type Create struct { type Create struct {
Date string `json:"date" validate:"required"` Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"` KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"` Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"` Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date"`
} }
type Update struct { type Update struct {