mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
2011 lines
59 KiB
Go
2011 lines
59 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
|
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
|
|
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sirupsen/logrus"
|
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type DailyChecklistService interface {
|
|
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error)
|
|
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
|
|
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
|
|
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
|
|
BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error)
|
|
DeleteOne(ctx *fiber.Ctx, id uint) error
|
|
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
|
|
AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error
|
|
RemoveAssignment(ctx *fiber.Ctx, id uint, employeeID uint) error
|
|
GetTasks(ctx *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error)
|
|
UpdateAssignment(ctx *fiber.Ctx, req *validation.UpdateAssignment) error
|
|
GetChecklistPhaseIDs(ctx *fiber.Ctx, checklistID uint) ([]uint, error)
|
|
GetDetail(ctx *fiber.Ctx, id uint) (*DailyChecklistDetail, error)
|
|
GetSummary(ctx *fiber.Ctx, params *validation.SummaryQuery) ([]DailyChecklistSummary, error)
|
|
GetReport(ctx *fiber.Ctx, params *validation.ReportQuery) ([]DailyChecklistReportItem, int64, error)
|
|
}
|
|
|
|
type dailyChecklistService struct {
|
|
Log *logrus.Logger
|
|
Validate *validator.Validate
|
|
Repository repository.DailyChecklistRepository
|
|
PhaseRepo phaseRepo.PhasesRepository
|
|
DocumentSvc commonSvc.DocumentService
|
|
}
|
|
|
|
type DailyChecklistDocument struct {
|
|
ID uint
|
|
Name string
|
|
Size float64
|
|
URL string
|
|
}
|
|
|
|
type DailyChecklistDetail struct {
|
|
Checklist entity.DailyChecklist
|
|
Phases []entity.DailyChecklistPhase
|
|
Tasks []entity.DailyChecklistActivityTask
|
|
AssignedEmployees []entity.Employee
|
|
TotalActivities int
|
|
Progress float64
|
|
DocumentURLs []DailyChecklistDocument
|
|
}
|
|
|
|
type DailyChecklistListItem struct {
|
|
ID uint
|
|
Name *string
|
|
Date time.Time
|
|
Category string
|
|
Status *string
|
|
RejectReason *string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Kandang entity.KandangGroup
|
|
TotalPhase int
|
|
TotalActivity int
|
|
Progress int
|
|
}
|
|
|
|
type DailyChecklistSummary struct {
|
|
EmployeeID uint
|
|
EmployeeName string
|
|
KandangID uint
|
|
KandangName string
|
|
TotalActivity int
|
|
ActivityDone int
|
|
ActivityLeft int
|
|
CompletionRate int
|
|
LastActivity *time.Time
|
|
}
|
|
|
|
type DailyChecklistReportItem struct {
|
|
AreaID uint
|
|
AreaName string
|
|
LocationID uint
|
|
LocationName string
|
|
KandangID uint
|
|
KandangName string
|
|
EmployeeID uint
|
|
EmployeeName string
|
|
PhaseName string
|
|
DailyActivities map[string]any
|
|
Summary DailyChecklistReportSummary
|
|
}
|
|
|
|
type DailyChecklistReportSummary struct {
|
|
TotalChecklist int
|
|
JumlahHariEfektif int
|
|
AbkPercentage int
|
|
KandangPercentage int
|
|
Category DailyChecklistReportCategory
|
|
}
|
|
|
|
type DailyChecklistReportCategory struct {
|
|
Kurang int
|
|
Cukup int
|
|
Baik int
|
|
}
|
|
|
|
const (
|
|
dailyChecklistDateLayout = "2006-01-02"
|
|
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
|
dailyChecklistStatusRejected = "REJECTED"
|
|
dailyChecklistStatusDraft = "DRAFT"
|
|
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
|
|
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"
|
|
)
|
|
|
|
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
|
return &dailyChecklistService{
|
|
Log: utils.Log,
|
|
Validate: validate,
|
|
Repository: repo,
|
|
PhaseRepo: phaseRepo,
|
|
DocumentSvc: documentSvc,
|
|
}
|
|
}
|
|
|
|
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("Kandang")
|
|
}
|
|
|
|
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
|
|
if checklistID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist id")
|
|
}
|
|
|
|
db := s.Repository.DB().WithContext(c.Context()).
|
|
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").
|
|
Where("dc.id = ?", checklistID).
|
|
Where("dc.deleted_at IS NULL")
|
|
|
|
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var count int64
|
|
if err := scopedDB.Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint) error {
|
|
if kandangID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
|
|
}
|
|
|
|
db := s.Repository.DB().WithContext(c.Context()).
|
|
Table("kandang_groups k").
|
|
Joins("JOIN locations loc ON loc.id = k.location_id").
|
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
|
Where("k.id = ?", kandangID)
|
|
|
|
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var count int64
|
|
if err := scopedDB.Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error {
|
|
if taskID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid task id")
|
|
}
|
|
|
|
db := s.Repository.DB().WithContext(c.Context()).
|
|
Table("daily_checklist_activity_tasks t").
|
|
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").
|
|
Where("t.id = ?", taskID)
|
|
|
|
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var count int64
|
|
if err := scopedDB.Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fiber.NewError(fiber.StatusNotFound, "Task not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (params.Page - 1) * params.Limit
|
|
|
|
db := s.Repository.DB().WithContext(c.Context()).
|
|
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").
|
|
Where("dc.deleted_at IS NULL")
|
|
|
|
var scopeErr error
|
|
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
|
|
if scopeErr != nil {
|
|
return nil, 0, scopeErr
|
|
}
|
|
|
|
if params.DateFrom != "" {
|
|
dateFrom, err := time.Parse("2006-01-02", params.DateFrom)
|
|
if err != nil {
|
|
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "invalid date_from format, use YYYY-MM-DD")
|
|
}
|
|
db = db.Where("dc.date >= ?", dateFrom)
|
|
}
|
|
|
|
if params.DateTo != "" {
|
|
dateTo, err := time.Parse("2006-01-02", params.DateTo)
|
|
if err != nil {
|
|
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "invalid date_to format, use YYYY-MM-DD")
|
|
}
|
|
db = db.Where("dc.date <= ?", dateTo)
|
|
}
|
|
|
|
if params.KandangID != nil {
|
|
db = db.Where("dc.kandang_id = ?", *params.KandangID)
|
|
}
|
|
|
|
if params.Status != "" {
|
|
db = db.Where("dc.status = ?", params.Status)
|
|
}
|
|
|
|
if params.Search != "" {
|
|
re := regexp.MustCompile("[^a-zA-Z0-9]")
|
|
normalizedSearch := re.ReplaceAllString(params.Search, "")
|
|
if normalizedSearch != "" {
|
|
like := "%" + normalizedSearch + "%"
|
|
db = db.Where(`(
|
|
regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
|
|
regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
|
|
(dc.category = 'empty_kandang' AND regexp_replace('Kandang Kosong', '[^a-zA-Z0-9]', '', 'g') ILIKE ?)
|
|
)`, like, like, like)
|
|
}
|
|
}
|
|
|
|
countDB := db.Session(&gorm.Session{})
|
|
var total int64
|
|
if err := countDB.Count(&total).Error; err != nil {
|
|
s.Log.Errorf("Failed to count dailyChecklists: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
type dailyChecklistListRow struct {
|
|
ID uint
|
|
Name *string
|
|
Date time.Time
|
|
Category string
|
|
Status *string
|
|
RejectReason *string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
KandangID uint
|
|
TotalPhase int64
|
|
TotalActivity int64
|
|
TotalAssignments int64
|
|
CompletedAssignments int64
|
|
}
|
|
|
|
rows := make([]dailyChecklistListRow, 0)
|
|
selectDB := db.Session(&gorm.Session{})
|
|
if err := selectDB.
|
|
Select(`
|
|
dc.id,
|
|
dc.name,
|
|
dc.date,
|
|
dc.category,
|
|
dc.status,
|
|
dc.reject_reason,
|
|
dc.created_at,
|
|
dc.updated_at,
|
|
dc.kandang_id,
|
|
COALESCE((
|
|
SELECT COUNT(*)
|
|
FROM daily_checklist_phases dcp
|
|
WHERE dcp.checklist_id = dc.id
|
|
), 0) AS total_phase,
|
|
COALESCE((
|
|
SELECT COUNT(pa.id)
|
|
FROM daily_checklist_phases dcp
|
|
JOIN phase_activities pa ON pa.phase_id = dcp.phase_id
|
|
WHERE dcp.checklist_id = dc.id AND pa.deleted_at IS NULL
|
|
), 0) AS total_activity,
|
|
COALESCE((
|
|
SELECT COUNT(*)
|
|
FROM daily_checklist_activity_task_assignments dca
|
|
JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id
|
|
WHERE dcat.checklist_id = dc.id
|
|
), 0) AS total_assignments,
|
|
COALESCE((
|
|
SELECT COUNT(*)
|
|
FROM daily_checklist_activity_task_assignments dca
|
|
JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id
|
|
WHERE dcat.checklist_id = dc.id AND dca.checked
|
|
), 0) AS completed_assignments`).
|
|
Order("dc.date DESC, dc.created_at DESC").
|
|
Offset(offset).
|
|
Limit(params.Limit).
|
|
Scan(&rows).Error; err != nil {
|
|
s.Log.Errorf("Failed to get dailyChecklists: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
kandangIDs := make([]uint, 0, len(rows))
|
|
seen := make(map[uint]struct{})
|
|
for _, row := range rows {
|
|
if _, ok := seen[row.KandangID]; !ok {
|
|
seen[row.KandangID] = struct{}{}
|
|
kandangIDs = append(kandangIDs, row.KandangID)
|
|
}
|
|
}
|
|
|
|
kandangMap := make(map[uint]entity.KandangGroup)
|
|
if len(kandangIDs) > 0 {
|
|
var kandangs []entity.KandangGroup
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Where("id IN ?", kandangIDs).
|
|
Preload("Location").
|
|
Preload("Pic").
|
|
Preload("CreatedUser").
|
|
Find(&kandangs).Error; err != nil {
|
|
s.Log.Errorf("Failed to get kandangs for daily checklist list: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
for _, kandang := range kandangs {
|
|
kandangMap[kandang.Id] = kandang
|
|
}
|
|
}
|
|
|
|
items := make([]DailyChecklistListItem, len(rows))
|
|
for i, row := range rows {
|
|
progress := 0
|
|
if row.TotalAssignments > 0 {
|
|
progress = int(math.Round(float64(row.CompletedAssignments) / float64(row.TotalAssignments) * 100))
|
|
}
|
|
|
|
items[i] = DailyChecklistListItem{
|
|
ID: row.ID,
|
|
Name: row.Name,
|
|
Date: row.Date,
|
|
Category: row.Category,
|
|
Status: row.Status,
|
|
RejectReason: row.RejectReason,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
Kandang: kandangMap[row.KandangID],
|
|
TotalPhase: int(row.TotalPhase),
|
|
TotalActivity: int(row.TotalActivity),
|
|
Progress: progress,
|
|
}
|
|
}
|
|
|
|
return items, total, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) {
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return nil, err
|
|
}
|
|
dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
if err != nil {
|
|
s.Log.Errorf("Failed get dailyChecklist by id: %+v", err)
|
|
return nil, err
|
|
}
|
|
return dailyChecklist, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetDetail(c *fiber.Ctx, id uint) (*DailyChecklistDetail, error) {
|
|
checklist, err := s.GetOne(c, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db := s.Repository.DB().WithContext(c.Context())
|
|
|
|
var phases []entity.DailyChecklistPhase
|
|
if err := db.
|
|
Where("checklist_id = ?", id).
|
|
Preload("Phase", func(tx *gorm.DB) *gorm.DB {
|
|
return tx.Preload("Activities")
|
|
}).
|
|
Order("created_at ASC").
|
|
Find(&phases).Error; err != nil {
|
|
s.Log.Errorf("Failed to get phases for daily checklist %d: %+v", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
var tasks []entity.DailyChecklistActivityTask
|
|
if err := db.
|
|
Where("checklist_id = ?", id).
|
|
Preload("Phase").
|
|
Preload("PhaseActivity").
|
|
Preload("Assignments", func(tx *gorm.DB) *gorm.DB {
|
|
return tx.Preload("Employee")
|
|
}).
|
|
Order("created_at ASC").
|
|
Find(&tasks).Error; err != nil {
|
|
s.Log.Errorf("Failed to get tasks for daily checklist %d: %+v", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
assignedEmployees := collectAssignedEmployees(tasks)
|
|
|
|
totalActivities := 0
|
|
for _, phase := range phases {
|
|
totalActivities += len(phase.Phase.Activities)
|
|
}
|
|
|
|
var totalAssignments, completedAssignments int
|
|
for _, task := range tasks {
|
|
for _, assignment := range task.Assignments {
|
|
totalAssignments++
|
|
if assignment.Checked {
|
|
completedAssignments++
|
|
}
|
|
}
|
|
}
|
|
|
|
var progress float64
|
|
if totalAssignments > 0 {
|
|
progress = math.Round((float64(completedAssignments) / float64(totalAssignments)) * 100)
|
|
}
|
|
|
|
documentURLs := make([]DailyChecklistDocument, 0)
|
|
if s.DocumentSvc != nil {
|
|
documents, err := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id))
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to list documents for daily checklist %d: %+v", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
for _, doc := range documents {
|
|
url, err := s.DocumentSvc.PresignURL(c.Context(), doc, 0)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to presign document %d for daily checklist %d: %+v", doc.Id, id, err)
|
|
continue
|
|
}
|
|
documentURLs = append(documentURLs, DailyChecklistDocument{
|
|
ID: doc.Id,
|
|
Name: doc.Name,
|
|
Size: doc.Size,
|
|
URL: url,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &DailyChecklistDetail{
|
|
Checklist: *checklist,
|
|
Phases: phases,
|
|
Tasks: tasks,
|
|
AssignedEmployees: assignedEmployees,
|
|
TotalActivities: totalActivities,
|
|
Progress: progress,
|
|
DocumentURLs: documentURLs,
|
|
}, nil
|
|
}
|
|
|
|
func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.ensureKandangAccess(c, req.KandangId); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
|
|
if req.EmptyKandang {
|
|
category = dailyChecklistCategoryEmptyKandang
|
|
}
|
|
|
|
targetID := uint(0)
|
|
|
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
|
|
return err
|
|
}
|
|
|
|
if category == dailyChecklistCategoryEmptyKandang {
|
|
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil {
|
|
return err
|
|
}
|
|
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
|
})
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetOne(c, targetID)
|
|
}
|
|
|
|
func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kandangID uint) error {
|
|
if kandangID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
|
|
}
|
|
|
|
var lockedKandangID uint
|
|
query := tx.Table("kandang_groups").Select("id").Where("id = ?", kandangID)
|
|
if tx.Dialector.Name() != "sqlite" {
|
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
}
|
|
|
|
if err := query.Take(&lockedKandangID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
|
|
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 {
|
|
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) error {
|
|
var conflictCount int64
|
|
if err := tx.Model(&entity.DailyChecklist{}).
|
|
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang).
|
|
Count(&conflictCount).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if conflictCount > 0 {
|
|
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
|
|
var conflictCount int64
|
|
if err := tx.Model(&entity.DailyChecklist{}).
|
|
Unscoped().
|
|
Where("kandang_id = ? AND date = ? AND deleted_at IS NULL AND category != ?", kandangID, date, dailyChecklistCategoryEmptyKandang).
|
|
Count(&conflictCount).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if conflictCount > 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrDeletedNonEmptyKandangExists)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deletedIDs := make([]uint, 0)
|
|
if req.DeletedDocumentIDs != nil {
|
|
parts := strings.Split(*req.DeletedDocumentIDs, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
parsedID, err := strconv.ParseUint(part, 10, 64)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid deleted_document_ids")
|
|
}
|
|
deletedIDs = append(deletedIDs, uint(parsedID))
|
|
}
|
|
}
|
|
|
|
updateBody := map[string]any{
|
|
"status": req.Status,
|
|
}
|
|
|
|
if req.RejectReason != nil {
|
|
updateBody["reject_reason"] = *req.RejectReason
|
|
}
|
|
|
|
actorID, err := m.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
|
}
|
|
|
|
if len(deletedIDs) > 0 && s.DocumentSvc != nil {
|
|
if err := s.DocumentSvc.DeleteDocuments(c.Context(), deletedIDs, true); err != nil {
|
|
s.Log.Errorf("Failed to delete daily checklist documents: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete daily checklist documents")
|
|
}
|
|
}
|
|
|
|
if len(req.Documents) > 0 {
|
|
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
|
for idx, file := range req.Documents {
|
|
documentFiles = append(documentFiles, commonSvc.DocumentFile{
|
|
File: file,
|
|
Type: string(utils.DocumentTypeDailyChecklist),
|
|
Index: &idx,
|
|
})
|
|
}
|
|
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentTypeDailyChecklist),
|
|
DocumentableID: uint64(id),
|
|
CreatedBy: &actorID,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to upload daily checklist documents: %+v", err)
|
|
return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload daily checklist documents")
|
|
}
|
|
}
|
|
|
|
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
s.Log.Errorf("Failed to update dailyChecklist: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetOne(c, id)
|
|
}
|
|
|
|
func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status := strings.ToUpper(strings.TrimSpace(req.Status))
|
|
if status != "APPROVED" && status != "REJECTED" {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "status must be APPROVED or REJECTED")
|
|
}
|
|
|
|
ids, err := parseChecklistIDs(req.IDs)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "ids cannot be empty")
|
|
}
|
|
|
|
scopedIDs, err := s.Repository.ListScopedChecklistIDs(c, ids)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to validate daily checklist scope for bulk update: %+v", err)
|
|
return nil, err
|
|
}
|
|
if len(scopedIDs) != len(ids) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
|
|
var rejectReason *string
|
|
if status == "REJECTED" {
|
|
rejectReason = req.RejectReason
|
|
}
|
|
|
|
if err := s.Repository.BulkUpdateStatus(c.Context(), ids, status, rejectReason); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
s.Log.Errorf("Failed to bulk update daily checklist status: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
updated, err := s.Repository.ListByIDsWithKandang(c.Context(), ids)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to fetch updated daily checklists: %+v", err)
|
|
return nil, err
|
|
}
|
|
if len(updated) != len(ids) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
|
|
orderByID := make(map[uint]int, len(ids))
|
|
for idx, id := range ids {
|
|
orderByID[id] = idx
|
|
}
|
|
|
|
sort.Slice(updated, func(i, j int) bool {
|
|
return orderByID[updated[i].Id] < orderByID[updated[j].Id]
|
|
})
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return err
|
|
}
|
|
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")
|
|
}
|
|
s.Log.Errorf("Failed to delete dailyChecklist: %+v", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validation.AssignPhases) error {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
phaseIDs, err := parsePhaseIDs(req.PhaseIDs)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
if len(phaseIDs) > 0 {
|
|
phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
|
|
}
|
|
return err
|
|
}
|
|
if len(phases) != len(phaseIDs) {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Phase not found")
|
|
}
|
|
}
|
|
|
|
db := s.Repository.DB()
|
|
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistPhase{}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(phaseIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
records := make([]entity.DailyChecklistPhase, 0, len(phaseIDs))
|
|
for _, pid := range phaseIDs {
|
|
records = append(records, entity.DailyChecklistPhase{
|
|
ChecklistId: id,
|
|
PhaseId: pid,
|
|
})
|
|
}
|
|
|
|
if err := tx.Create(&records).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
var activities []entity.PhaseActivity
|
|
if err := tx.Where("phase_id IN ?", phaseIDs).Find(&activities).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
activityRecords := make([]entity.DailyChecklistActivityTask, 0, len(activities))
|
|
for _, activity := range activities {
|
|
activityRecords = append(activityRecords, entity.DailyChecklistActivityTask{
|
|
ChecklistId: id,
|
|
PhaseId: activity.PhaseId,
|
|
PhaseActivityId: activity.Id,
|
|
TimeType: activity.TimeType,
|
|
})
|
|
}
|
|
|
|
if len(activityRecords) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return tx.Create(&activityRecords).Error
|
|
}); err != nil {
|
|
s.Log.Errorf("Failed to assign phases to daily checklist: %+v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error {
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
if employeeID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id")
|
|
}
|
|
|
|
db := s.Repository.DB()
|
|
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
var tasks []entity.DailyChecklistActivityTask
|
|
if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist")
|
|
}
|
|
|
|
taskIDs := collectTaskIDs(tasks)
|
|
return tx.Where("task_id IN ? AND employee_id = ?", taskIDs, employeeID).
|
|
Delete(&entity.DailyChecklistActivityTaskAssignment{}).Error
|
|
}); err != nil {
|
|
s.Log.Errorf("Failed to remove assignment: %+v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) {
|
|
if checklistID == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
|
|
}
|
|
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var tasks []entity.DailyChecklistActivityTask
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Where("checklist_id = ?", checklistID).
|
|
Order("created_at ASC").
|
|
Find(&tasks).Error; err != nil {
|
|
s.Log.Errorf("Failed to get daily checklist tasks: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID uint) ([]uint, error) {
|
|
if checklistID == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
|
|
}
|
|
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var phases []entity.DailyChecklistPhase
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Where("checklist_id = ?", checklistID).
|
|
Order("created_at ASC").
|
|
Find(&phases).Error; err != nil {
|
|
s.Log.Errorf("Failed to get daily checklist phases: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
phaseIDs := make([]uint, len(phases))
|
|
for i, p := range phases {
|
|
phaseIDs[i] = p.PhaseId
|
|
}
|
|
|
|
return phaseIDs, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.UpdateAssignment) error {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureTaskAccess(c, req.TaskID); err != nil {
|
|
return err
|
|
}
|
|
|
|
task := new(entity.DailyChecklistActivityTask)
|
|
if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Task not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
if req.EmployeeID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id")
|
|
}
|
|
|
|
updates := map[string]any{"updated_at": time.Now()}
|
|
if req.Checked != nil {
|
|
updates["checked"] = *req.Checked
|
|
}
|
|
if req.Note != nil {
|
|
updates["note"] = *req.Note
|
|
}
|
|
|
|
return s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}},
|
|
DoUpdates: clause.Assignments(updates),
|
|
}).Create(&entity.DailyChecklistActivityTaskAssignment{
|
|
TaskId: req.TaskID,
|
|
EmployeeId: req.EmployeeID,
|
|
Checked: req.Checked != nil && *req.Checked,
|
|
Note: req.Note,
|
|
}).Error
|
|
}
|
|
|
|
func parsePhaseIDs(raw string) ([]uint, error) {
|
|
parts := strings.Split(raw, ",")
|
|
result := make([]uint, 0, len(parts))
|
|
seen := make(map[uint]struct{})
|
|
|
|
for _, part := range parts {
|
|
value := strings.TrimSpace(part)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
num, err := strconv.ParseUint(value, 10, 64)
|
|
if err != nil {
|
|
return nil, errors.New("invalid phase id: " + value)
|
|
}
|
|
u := uint(num)
|
|
if _, ok := seen[u]; ok {
|
|
continue
|
|
}
|
|
seen[u] = struct{}{}
|
|
result = append(result, u)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseChecklistIDs(raw string) ([]uint, error) {
|
|
parts := strings.Split(raw, ",")
|
|
result := make([]uint, 0, len(parts))
|
|
seen := make(map[uint]struct{})
|
|
|
|
for _, part := range parts {
|
|
value := strings.TrimSpace(part)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
num, err := strconv.ParseUint(value, 10, 64)
|
|
if err != nil || num == 0 {
|
|
return nil, errors.New("invalid daily checklist id: " + value)
|
|
}
|
|
|
|
u := uint(num)
|
|
if _, ok := seen[u]; ok {
|
|
continue
|
|
}
|
|
seen[u] = struct{}{}
|
|
result = append(result, u)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseIDs(raw string) ([]uint, error) {
|
|
parts := strings.Split(raw, ",")
|
|
result := make([]uint, 0, len(parts))
|
|
seen := make(map[uint]struct{})
|
|
|
|
for _, part := range parts {
|
|
value := strings.TrimSpace(part)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
num, err := strconv.ParseUint(value, 10, 64)
|
|
if err != nil {
|
|
return nil, errors.New("invalid employee id: " + value)
|
|
}
|
|
u := uint(num)
|
|
if _, ok := seen[u]; ok {
|
|
continue
|
|
}
|
|
seen[u] = struct{}{}
|
|
result = append(result, u)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint {
|
|
result := make([]uint, len(tasks))
|
|
for i, task := range tasks {
|
|
result[i] = task.Id
|
|
}
|
|
return result
|
|
}
|
|
|
|
func collectAssignedEmployees(tasks []entity.DailyChecklistActivityTask) []entity.Employee {
|
|
employeeMap := make(map[uint]entity.Employee)
|
|
for _, task := range tasks {
|
|
for _, assignment := range task.Assignments {
|
|
if assignment.Employee.Id == 0 {
|
|
continue
|
|
}
|
|
if _, exists := employeeMap[assignment.Employee.Id]; exists {
|
|
continue
|
|
}
|
|
employeeMap[assignment.Employee.Id] = assignment.Employee
|
|
}
|
|
}
|
|
|
|
employees := make([]entity.Employee, 0, len(employeeMap))
|
|
for _, emp := range employeeMap {
|
|
employees = append(employees, emp)
|
|
}
|
|
|
|
sort.Slice(employees, func(i, j int) bool {
|
|
return employees[i].Id < employees[j].Id
|
|
})
|
|
|
|
return employees
|
|
}
|
|
func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validation.AssignTask) error {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureChecklistAccess(c, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
employeeIDs, err := parseIDs(req.EmployeeIDs)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
if len(employeeIDs) == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "employee_ids cannot be empty")
|
|
}
|
|
|
|
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
db := s.Repository.DB()
|
|
if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
var tasks []entity.DailyChecklistActivityTask
|
|
if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist")
|
|
}
|
|
|
|
assignments := make([]entity.DailyChecklistActivityTaskAssignment, 0, len(tasks)*len(employeeIDs))
|
|
for _, task := range tasks {
|
|
for _, empID := range employeeIDs {
|
|
assignments = append(assignments, entity.DailyChecklistActivityTaskAssignment{
|
|
TaskId: task.Id,
|
|
EmployeeId: empID,
|
|
})
|
|
}
|
|
}
|
|
|
|
return tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}},
|
|
DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}),
|
|
}).Create(&assignments).Error
|
|
}); err != nil {
|
|
s.Log.Errorf("Failed to assign tasks to daily checklist: %+v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.SummaryQuery) ([]DailyChecklistSummary, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dateFrom, err := time.Parse("2006-01-02", params.DateFrom)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date_from format, use YYYY-MM-DD")
|
|
}
|
|
|
|
dateTo, err := time.Parse("2006-01-02", params.DateTo)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date_to format, use YYYY-MM-DD")
|
|
}
|
|
|
|
type summaryRow struct {
|
|
EmployeeID uint
|
|
EmployeeName string
|
|
KandangID uint
|
|
KandangName string
|
|
TotalActivity int64
|
|
ActivityDone int64
|
|
ActivityLeft int64
|
|
LastActivity *time.Time
|
|
}
|
|
|
|
rows := make([]summaryRow, 0)
|
|
db := s.Repository.DB().WithContext(c.Context()).
|
|
Table("daily_checklist_activity_task_assignments AS a").
|
|
Select(`
|
|
a.employee_id,
|
|
e.name AS employee_name,
|
|
d.kandang_id,
|
|
k.name AS kandang_name,
|
|
COUNT(*) AS total_activity,
|
|
SUM(CASE WHEN a.checked THEN 1 ELSE 0 END) AS activity_done,
|
|
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 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").
|
|
Joins("JOIN areas ar ON ar.id = loc.area_id").
|
|
Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED")
|
|
|
|
var scopeErr error
|
|
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "ar.id")
|
|
if scopeErr != nil {
|
|
return nil, scopeErr
|
|
}
|
|
|
|
if params.Category != "" {
|
|
db = db.Where("d.category = ?", params.Category)
|
|
}
|
|
|
|
if params.KandangID != nil {
|
|
db = db.Where("d.kandang_id = ?", *params.KandangID)
|
|
}
|
|
|
|
if err := db.
|
|
Group("a.employee_id, e.name, d.kandang_id, k.name").
|
|
Order("e.name ASC").
|
|
Find(&rows).Error; err != nil {
|
|
s.Log.Errorf("Failed to get daily checklist summary: %+v", err)
|
|
return nil, err
|
|
}
|
|
|
|
summaries := make([]DailyChecklistSummary, len(rows))
|
|
for i, row := range rows {
|
|
completionRate := 0
|
|
if row.TotalActivity > 0 {
|
|
completionRate = int(math.Round(float64(row.ActivityDone) / float64(row.TotalActivity) * 100))
|
|
}
|
|
|
|
summaries[i] = DailyChecklistSummary{
|
|
EmployeeID: row.EmployeeID,
|
|
EmployeeName: row.EmployeeName,
|
|
KandangID: row.KandangID,
|
|
KandangName: row.KandangName,
|
|
TotalActivity: int(row.TotalActivity),
|
|
ActivityDone: int(row.ActivityDone),
|
|
ActivityLeft: int(row.ActivityLeft),
|
|
CompletionRate: completionRate,
|
|
LastActivity: row.LastActivity,
|
|
}
|
|
}
|
|
|
|
return summaries, nil
|
|
}
|
|
|
|
func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.ReportQuery) ([]DailyChecklistReportItem, int64, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
locationScope, err := m.ResolveLocationScope(c, s.Repository.DB())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
areaScope, err := m.ResolveAreaScope(c, s.Repository.DB())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (params.Page - 1) * params.Limit
|
|
|
|
buildBase := func() *gorm.DB {
|
|
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 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").
|
|
Joins("JOIN areas a ON a.id = loc.area_id").
|
|
Joins("JOIN phases p ON p.id = dcat.phase_id").
|
|
Where("EXTRACT(MONTH FROM dc.date) = ?", params.Month).
|
|
Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year).
|
|
Where("dc.status = ?", "APPROVED")
|
|
|
|
db = m.ApplyScopeFilter(db, locationScope, "loc.id")
|
|
db = m.ApplyScopeFilter(db, areaScope, "a.id")
|
|
|
|
if params.AreaID != nil {
|
|
db = db.Where("a.id = ?", *params.AreaID)
|
|
}
|
|
if params.LocationID != nil {
|
|
db = db.Where("loc.id = ?", *params.LocationID)
|
|
}
|
|
if params.KandangID != nil {
|
|
db = db.Where("k.id = ?", *params.KandangID)
|
|
}
|
|
if params.EmployeeID != nil {
|
|
db = db.Where("dca.employee_id = ?", *params.EmployeeID)
|
|
}
|
|
if params.PhaseID != nil {
|
|
db = db.Where("p.id = ?", *params.PhaseID)
|
|
}
|
|
return db
|
|
}
|
|
|
|
buildGroupedQuery := func() *gorm.DB {
|
|
return buildBase().
|
|
Select(`
|
|
a.id AS area_id,
|
|
a.name AS area_name,
|
|
loc.id AS location_id,
|
|
loc.name AS location_name,
|
|
k.id AS kandang_id,
|
|
k.name AS kandang_name,
|
|
e.id AS employee_id,
|
|
e.name AS employee_name,
|
|
p.id AS phase_id,
|
|
p.name AS phase_name,
|
|
SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed_assignments,
|
|
COUNT(*) AS total_assignments`).
|
|
Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name")
|
|
}
|
|
|
|
// --- Count approved rows ---
|
|
var approvedTotal int64
|
|
groupedForCount := buildGroupedQuery()
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Table("(?) AS grouped", groupedForCount).
|
|
Count(&approvedTotal).Error; err != nil {
|
|
s.Log.Errorf("Failed to count report data: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
type reportRow struct {
|
|
AreaID uint
|
|
AreaName string
|
|
LocationID uint
|
|
LocationName string
|
|
KandangID uint
|
|
KandangName string
|
|
EmployeeID uint
|
|
EmployeeName string
|
|
PhaseID uint
|
|
PhaseName string
|
|
CompletedAssignments int64
|
|
TotalAssignments int64
|
|
}
|
|
|
|
type fallbackRowType struct {
|
|
AreaID uint
|
|
AreaName string
|
|
LocationID uint
|
|
LocationName string
|
|
KandangID uint
|
|
KandangName string
|
|
EmployeeID uint
|
|
EmployeeName string
|
|
}
|
|
|
|
// buildFallbackQ returns employees in kandangs that have NO approved checklist data
|
|
// for the filtered period. Applies the same scope/area/location/kandang/employee filters.
|
|
buildFallbackQ := func() *gorm.DB {
|
|
approvedKandangSubQ := buildBase().Select("DISTINCT dc.kandang_id")
|
|
q := s.Repository.DB().WithContext(c.Context()).
|
|
Table("employee_kandangs ek").
|
|
Joins("JOIN employees e ON e.id = ek.employee_id AND e.deleted_at IS NULL").
|
|
Joins("JOIN kandang_groups k ON k.id = ek.kandang_id AND k.deleted_at IS NULL").
|
|
Joins("JOIN locations loc ON loc.id = k.location_id AND loc.deleted_at IS NULL").
|
|
Joins("JOIN areas a ON a.id = loc.area_id AND a.deleted_at IS NULL").
|
|
Where("ek.kandang_id NOT IN (?)", approvedKandangSubQ).
|
|
Select("e.id AS employee_id, e.name AS employee_name, k.id AS kandang_id, k.name AS kandang_name, loc.id AS location_id, loc.name AS location_name, a.id AS area_id, a.name AS area_name")
|
|
q = m.ApplyScopeFilter(q, locationScope, "loc.id")
|
|
q = m.ApplyScopeFilter(q, areaScope, "a.id")
|
|
if params.AreaID != nil {
|
|
q = q.Where("a.id = ?", *params.AreaID)
|
|
}
|
|
if params.LocationID != nil {
|
|
q = q.Where("loc.id = ?", *params.LocationID)
|
|
}
|
|
if params.KandangID != nil {
|
|
q = q.Where("ek.kandang_id = ?", *params.KandangID)
|
|
}
|
|
if params.EmployeeID != nil {
|
|
q = q.Where("ek.employee_id = ?", *params.EmployeeID)
|
|
}
|
|
// PhaseID not applied: fallback rows have no phase data
|
|
return q
|
|
}
|
|
|
|
// --- Count fallback rows ---
|
|
var fallbackTotal int64
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Table("(?) AS fb", buildFallbackQ()).
|
|
Count(&fallbackTotal).Error; err != nil {
|
|
s.Log.Errorf("Failed to count fallback report data: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
total := approvedTotal + fallbackTotal
|
|
|
|
// --- Fetch ALL approved rows (pagination done in Go after merging with fallback) ---
|
|
allApprovedRows := make([]reportRow, 0)
|
|
if approvedTotal > 0 {
|
|
if err := buildGroupedQuery().
|
|
Order("a.name, loc.name, k.name, e.name").
|
|
Scan(&allApprovedRows).Error; err != nil {
|
|
s.Log.Errorf("Failed to fetch report data: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
// --- Fetch ALL fallback rows ---
|
|
allFallbackRows := make([]fallbackRowType, 0)
|
|
if fallbackTotal > 0 {
|
|
if err := buildFallbackQ().
|
|
Order("a.name, loc.name, k.name, e.name").
|
|
Scan(&allFallbackRows).Error; err != nil {
|
|
s.Log.Errorf("Failed to fetch fallback report data: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
// --- Merge approved + fallback and sort consistently ---
|
|
type mergedEntry struct {
|
|
AreaName string
|
|
LocationName string
|
|
KandangName string
|
|
EmployeeName string
|
|
IsApproved bool
|
|
Idx int
|
|
}
|
|
|
|
merged := make([]mergedEntry, 0, len(allApprovedRows)+len(allFallbackRows))
|
|
for i, r := range allApprovedRows {
|
|
merged = append(merged, mergedEntry{
|
|
AreaName: r.AreaName, LocationName: r.LocationName,
|
|
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
|
|
IsApproved: true, Idx: i,
|
|
})
|
|
}
|
|
for i, r := range allFallbackRows {
|
|
merged = append(merged, mergedEntry{
|
|
AreaName: r.AreaName, LocationName: r.LocationName,
|
|
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
|
|
IsApproved: false, Idx: i,
|
|
})
|
|
}
|
|
sort.Slice(merged, func(i, j int) bool {
|
|
a, b := merged[i], merged[j]
|
|
if a.AreaName != b.AreaName {
|
|
return a.AreaName < b.AreaName
|
|
}
|
|
if a.LocationName != b.LocationName {
|
|
return a.LocationName < b.LocationName
|
|
}
|
|
if a.KandangName != b.KandangName {
|
|
return a.KandangName < b.KandangName
|
|
}
|
|
return a.EmployeeName < b.EmployeeName
|
|
})
|
|
|
|
// --- Apply Go-level pagination ---
|
|
end := offset + params.Limit
|
|
if end > len(merged) {
|
|
end = len(merged)
|
|
}
|
|
if offset >= len(merged) {
|
|
return []DailyChecklistReportItem{}, total, nil
|
|
}
|
|
pageData := merged[offset:end]
|
|
|
|
// --- Split page into approved vs fallback rows ---
|
|
pageApproved := make([]reportRow, 0)
|
|
pageFallback := make([]fallbackRowType, 0)
|
|
for _, entry := range pageData {
|
|
if entry.IsApproved {
|
|
pageApproved = append(pageApproved, allApprovedRows[entry.Idx])
|
|
} else {
|
|
pageFallback = append(pageFallback, allFallbackRows[entry.Idx])
|
|
}
|
|
}
|
|
|
|
applyEmptyKandangFlags := func(items []DailyChecklistReportItem, kandangIDs []uint) error {
|
|
if len(kandangIDs) == 0 {
|
|
return nil
|
|
}
|
|
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 {
|
|
KandangID uint
|
|
Date time.Time
|
|
}
|
|
var emptyRecs []emptyKandangRec
|
|
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)
|
|
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
|
|
}
|
|
}
|
|
|
|
type checklistDateRec struct {
|
|
KandangID uint
|
|
Date time.Time
|
|
}
|
|
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 i, item := range items {
|
|
daySet := emptyDaysByKandang[item.KandangID]
|
|
for day := range daySet {
|
|
key := strconv.Itoa(day)
|
|
if _, exists := items[i].DailyActivities[key]; !exists {
|
|
items[i].DailyActivities[key] = "Kandang kosong"
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type comboKey struct {
|
|
EmployeeID uint
|
|
KandangID uint
|
|
PhaseID uint
|
|
}
|
|
|
|
type dailyActivityStat struct {
|
|
Completed int
|
|
Total int
|
|
Date time.Time
|
|
}
|
|
|
|
employeeIDs := make([]uint, 0)
|
|
kandangIDs := make([]uint, 0)
|
|
phaseIDs := make([]uint, 0)
|
|
comboSet := make(map[comboKey]struct{})
|
|
employeeSet := make(map[uint]struct{})
|
|
kandangSet := make(map[uint]struct{})
|
|
phaseSet := make(map[uint]struct{})
|
|
|
|
for _, row := range pageApproved {
|
|
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
|
|
comboSet[key] = struct{}{}
|
|
if _, ok := employeeSet[row.EmployeeID]; !ok {
|
|
employeeSet[row.EmployeeID] = struct{}{}
|
|
employeeIDs = append(employeeIDs, row.EmployeeID)
|
|
}
|
|
if _, ok := kandangSet[row.KandangID]; !ok {
|
|
kandangSet[row.KandangID] = struct{}{}
|
|
kandangIDs = append(kandangIDs, row.KandangID)
|
|
}
|
|
if _, ok := phaseSet[row.PhaseID]; !ok {
|
|
phaseSet[row.PhaseID] = struct{}{}
|
|
phaseIDs = append(phaseIDs, row.PhaseID)
|
|
}
|
|
}
|
|
|
|
dailyActivityMap := make(map[comboKey]map[string]dailyActivityStat)
|
|
if len(employeeIDs) > 0 {
|
|
var dailyRows []struct {
|
|
EmployeeID uint
|
|
KandangID uint
|
|
PhaseID uint
|
|
Date time.Time
|
|
Completed int64
|
|
Total int64
|
|
}
|
|
|
|
dailyQuery := buildBase().
|
|
Where("dca.employee_id IN ?", employeeIDs).
|
|
Where("dc.kandang_id IN ?", kandangIDs).
|
|
Where("dcat.phase_id IN ?", phaseIDs).
|
|
Select(`
|
|
dca.employee_id,
|
|
dc.kandang_id,
|
|
dcat.phase_id,
|
|
dc.date,
|
|
SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed,
|
|
COUNT(*) AS total`).
|
|
Group("dca.employee_id, dc.kandang_id, dcat.phase_id, dc.date")
|
|
|
|
if err := dailyQuery.Scan(&dailyRows).Error; err != nil {
|
|
s.Log.Errorf("Failed to fetch daily activities for report: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
for _, row := range dailyRows {
|
|
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
|
|
if _, ok := comboSet[key]; !ok {
|
|
continue
|
|
}
|
|
if _, ok := dailyActivityMap[key]; !ok {
|
|
dailyActivityMap[key] = make(map[string]dailyActivityStat)
|
|
}
|
|
day := strconv.Itoa(row.Date.Day())
|
|
dailyActivityMap[key][day] = dailyActivityStat{
|
|
Completed: int(row.Completed),
|
|
Total: int(row.Total),
|
|
Date: row.Date,
|
|
}
|
|
}
|
|
}
|
|
|
|
employeeStats := make(map[uint]struct {
|
|
Completed int64
|
|
Total int64
|
|
})
|
|
var employeeRows []struct {
|
|
EmployeeID uint
|
|
Completed int64
|
|
Total int64
|
|
}
|
|
if err := buildBase().
|
|
Select(`
|
|
dca.employee_id,
|
|
SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed,
|
|
COUNT(*) AS total`).
|
|
Group("dca.employee_id").
|
|
Scan(&employeeRows).Error; err != nil {
|
|
s.Log.Errorf("Failed to fetch employee stats for report: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
for _, row := range employeeRows {
|
|
employeeStats[row.EmployeeID] = struct {
|
|
Completed int64
|
|
Total int64
|
|
}{Completed: row.Completed, Total: row.Total}
|
|
}
|
|
|
|
kandangStats := make(map[uint]struct {
|
|
Completed int64
|
|
Total int64
|
|
})
|
|
var kandangRows []struct {
|
|
KandangID uint
|
|
Completed int64
|
|
Total int64
|
|
}
|
|
if err := buildBase().
|
|
Select(`
|
|
dc.kandang_id,
|
|
SUM(CASE WHEN dca.checked THEN 1 ELSE 0 END) AS completed,
|
|
COUNT(*) AS total`).
|
|
Group("dc.kandang_id").
|
|
Scan(&kandangRows).Error; err != nil {
|
|
s.Log.Errorf("Failed to fetch kandang stats for report: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
for _, row := range kandangRows {
|
|
kandangStats[row.KandangID] = struct {
|
|
Completed int64
|
|
Total int64
|
|
}{Completed: row.Completed, Total: row.Total}
|
|
}
|
|
|
|
var configs []entity.ConfigChecklist
|
|
if err := s.Repository.DB().WithContext(c.Context()).
|
|
Order("date ASC").
|
|
Find(&configs).Error; err != nil {
|
|
s.Log.Errorf("Failed to load config checklists: %+v", err)
|
|
return nil, 0, err
|
|
}
|
|
|
|
getConfigForDate := func(date time.Time) *entity.ConfigChecklist {
|
|
var selected *entity.ConfigChecklist
|
|
for i := range configs {
|
|
if !configs[i].Date.After(date) {
|
|
selected = &configs[i]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if selected == nil {
|
|
return &entity.ConfigChecklist{
|
|
PercentageThresholdBad: 50,
|
|
PercentageThresholdEnough: 75,
|
|
}
|
|
}
|
|
return selected
|
|
}
|
|
|
|
// --- Build approved items (existing logic) ---
|
|
approvedItems := make([]DailyChecklistReportItem, len(pageApproved))
|
|
for i, row := range pageApproved {
|
|
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
|
|
|
|
activities := dailyActivityMap[key]
|
|
if activities == nil {
|
|
activities = map[string]dailyActivityStat{}
|
|
}
|
|
|
|
totalChecklist := 0
|
|
categoryCounts := DailyChecklistReportCategory{}
|
|
activityOutput := make(map[string]any, len(activities))
|
|
|
|
for day, stat := range activities {
|
|
activityOutput[day] = stat.Completed
|
|
totalChecklist += stat.Completed
|
|
|
|
if stat.Total == 0 {
|
|
continue
|
|
}
|
|
|
|
cfg := getConfigForDate(stat.Date)
|
|
if cfg == nil {
|
|
continue
|
|
}
|
|
|
|
progress := int(math.Ceil(float64(stat.Completed) / float64(stat.Total) * 100))
|
|
if progress <= cfg.PercentageThresholdBad {
|
|
categoryCounts.Kurang++
|
|
} else if progress <= cfg.PercentageThresholdEnough {
|
|
categoryCounts.Cukup++
|
|
} else {
|
|
categoryCounts.Baik++
|
|
}
|
|
}
|
|
|
|
employeeStat := employeeStats[row.EmployeeID]
|
|
abkPercentage := 0
|
|
if employeeStat.Total > 0 {
|
|
abkPercentage = int(math.Round(float64(employeeStat.Completed) / float64(employeeStat.Total) * 100))
|
|
}
|
|
|
|
kandangStat := kandangStats[row.KandangID]
|
|
kandangPercentage := 0
|
|
if kandangStat.Total > 0 {
|
|
kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100))
|
|
}
|
|
|
|
approvedItems[i] = DailyChecklistReportItem{
|
|
AreaID: row.AreaID,
|
|
AreaName: row.AreaName,
|
|
LocationID: row.LocationID,
|
|
LocationName: row.LocationName,
|
|
KandangID: row.KandangID,
|
|
KandangName: row.KandangName,
|
|
EmployeeID: row.EmployeeID,
|
|
EmployeeName: row.EmployeeName,
|
|
PhaseName: row.PhaseName,
|
|
DailyActivities: activityOutput,
|
|
Summary: DailyChecklistReportSummary{
|
|
TotalChecklist: totalChecklist,
|
|
JumlahHariEfektif: len(activities),
|
|
AbkPercentage: abkPercentage,
|
|
KandangPercentage: kandangPercentage,
|
|
Category: categoryCounts,
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- Build fallback items (kandangs with no approved data) ---
|
|
fallbackItems := make([]DailyChecklistReportItem, len(pageFallback))
|
|
for i, fb := range pageFallback {
|
|
fallbackItems[i] = DailyChecklistReportItem{
|
|
AreaID: fb.AreaID,
|
|
AreaName: fb.AreaName,
|
|
LocationID: fb.LocationID,
|
|
LocationName: fb.LocationName,
|
|
KandangID: fb.KandangID,
|
|
KandangName: fb.KandangName,
|
|
EmployeeID: fb.EmployeeID,
|
|
EmployeeName: fb.EmployeeName,
|
|
PhaseName: "",
|
|
DailyActivities: map[string]any{},
|
|
Summary: DailyChecklistReportSummary{},
|
|
}
|
|
}
|
|
|
|
// --- Reconstruct allItems in the sorted pageData order ---
|
|
allItems := make([]DailyChecklistReportItem, len(pageData))
|
|
approvedIdx := 0
|
|
fallbackIdx := 0
|
|
for i, entry := range pageData {
|
|
if entry.IsApproved {
|
|
allItems[i] = approvedItems[approvedIdx]
|
|
approvedIdx++
|
|
} else {
|
|
allItems[i] = fallbackItems[fallbackIdx]
|
|
fallbackIdx++
|
|
}
|
|
}
|
|
|
|
// --- Collect all kandangIDs on this page (approved + fallback) for empty_kandang flags ---
|
|
allKandangSet := make(map[uint]struct{})
|
|
for _, id := range kandangIDs {
|
|
allKandangSet[id] = struct{}{}
|
|
}
|
|
for _, fb := range pageFallback {
|
|
allKandangSet[fb.KandangID] = struct{}{}
|
|
}
|
|
allKandangIDs := make([]uint, 0, len(allKandangSet))
|
|
for id := range allKandangSet {
|
|
allKandangIDs = append(allKandangIDs, id)
|
|
}
|
|
|
|
// --- Flag empty kandang days within this report month ---
|
|
if err := applyEmptyKandangFlags(allItems, allKandangIDs); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return allItems, total, nil
|
|
}
|