diff --git a/internal/config/fiber.go b/internal/config/fiber.go index aea67b5f..40c2c818 100644 --- a/internal/config/fiber.go +++ b/internal/config/fiber.go @@ -13,6 +13,7 @@ func FiberConfig() fiber.Config { CaseSensitive: true, ServerHeader: "Fiber", AppName: "Fiber API", + BodyLimit: 8 * 1024 * 1024, ErrorHandler: utils.ErrorHandler, JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql new file mode 100644 index 00000000..bb7b412d --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.down.sql @@ -0,0 +1,7 @@ +-- Remove chart_data, uniform_date, and related indexes +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_uniform_date; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_unique; + +ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN IF EXISTS chart_data, + DROP COLUMN IF EXISTS uniform_date; diff --git a/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql new file mode 100644 index 00000000..4c256096 --- /dev/null +++ b/internal/database/migrations/20260107075608_alter_uniformity_table.up.sql @@ -0,0 +1,25 @@ +-- Add uniform_date (if missing), chart_data, and unique constraint for uniformity records +ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN IF NOT EXISTS uniform_date TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS chart_data JSONB; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date) + WHERE deleted_at IS NULL; + ELSE + CREATE UNIQUE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_unique + ON project_flock_kandang_uniformity (project_flock_kandang_id, week, uniform_date); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_uniform_date + ON project_flock_kandang_uniformity (uniform_date); diff --git a/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql b/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql new file mode 100644 index 00000000..e4c4d3dd --- /dev/null +++ b/internal/database/migrations/20260107120000_create_config_checklists_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS config_checklists; diff --git a/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql b/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql new file mode 100644 index 00000000..57589f31 --- /dev/null +++ b/internal/database/migrations/20260107120000_create_config_checklists_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS config_checklists ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + percentage_threshold_bad INTEGER NOT NULL, + percentage_threshold_enough INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); diff --git a/internal/entities/config-checklist.go b/internal/entities/config-checklist.go new file mode 100644 index 00000000..563d88de --- /dev/null +++ b/internal/entities/config-checklist.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ConfigChecklist struct { + Id uint `gorm:"primaryKey"` + Date time.Time `gorm:"type:date;not null"` + PercentageThresholdBad int `gorm:"not null"` + PercentageThresholdEnough int `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go index bf320c72..9171eefa 100644 --- a/internal/entities/project_flock_kandang_uniformity.go +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -1,20 +1,24 @@ package entities -import "time" +import ( + "encoding/json" + "time" +) type ProjectFlockKandangUniformity struct { - Id uint `gorm:"primaryKey"` - Uniformity float64 `gorm:"type:numeric(15,3)"` - Week int `gorm:"not null"` - Cv float64 `gorm:"type:numeric(15,3)"` - ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` - MeanUp float64 `gorm:"type:numeric(15,3)"` - MeanDown float64 `gorm:"type:numeric(15,3)"` - ProjectFlockKandangId uint `gorm:"not null"` - UniformQty float64 `gorm:"type:numeric(15,3)"` - NotUniformQty float64 `gorm:"type:numeric(15,3)"` - UniformDate *time.Time `gorm:"type:timestamptz"` - CreatedBy uint `gorm:"not null"` + Id uint `gorm:"primaryKey"` + Uniformity float64 `gorm:"type:numeric(15,3)"` + Week int `gorm:"not null"` + Cv float64 `gorm:"type:numeric(15,3)"` + ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` + MeanUp float64 `gorm:"type:numeric(15,3)"` + MeanDown float64 `gorm:"type:numeric(15,3)"` + ProjectFlockKandangId uint `gorm:"not null"` + UniformQty float64 `gorm:"type:numeric(15,3)"` + NotUniformQty float64 `gorm:"type:numeric(15,3)"` + ChartData json.RawMessage `gorm:"type:jsonb"` + UniformDate *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 90546448..68269728 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -12,7 +12,7 @@ type RecordingEgg struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - ProductFlagName *string `gorm:"column:product_flag_name" json:"-"` + ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index b5a9b7b5..7c92664a 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" @@ -28,6 +29,18 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), } + query.DateFrom = c.Query("date_from", "") + query.DateTo = c.Query("date_to", "") + query.Status = c.Query("status", "") + + if kandangParam := c.Query("kandang_id", ""); kandangParam != "" { + kandangID, err := strconv.ParseUint(kandangParam, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + value := uint(kandangID) + query.KandangID = &value + } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") @@ -38,6 +51,40 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { return err } + responseData := make([]dto.DailyChecklistListDTO, len(result)) + for i, item := range result { + var name string + if item.Name != nil { + name = *item.Name + } + + var status string + if item.Status != nil { + status = *item.Status + } + + var kandang *kandangDTO.KandangRelationDTO + if item.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(item.Kandang) + kandang = &mapped + } + + responseData[i] = dto.DailyChecklistListDTO{ + Id: item.ID, + Name: name, + Status: status, + Category: item.Category, + Date: item.Date, + Kandang: kandang, + CreatedUser: nil, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + TotalPhase: item.TotalPhase, + TotalActivity: item.TotalActivity, + Progress: item.Progress, + } + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.DailyChecklistListDTO]{ Code: fiber.StatusOK, @@ -49,19 +96,209 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToDailyChecklistListDTOs(result), + Data: responseData, }) } +func (u *DailyChecklistController) GetSummary(c *fiber.Ctx) error { + query := &validation.SummaryQuery{ + DateFrom: c.Query("date_from"), + DateTo: c.Query("date_to"), + Category: c.Query("category"), + } + + if query.DateFrom == "" || query.DateTo == "" { + return fiber.NewError(fiber.StatusBadRequest, "date_from and date_to are required") + } + + if kandangParam := c.Query("kandang_id"); kandangParam != "" { + kandangID, err := strconv.ParseUint(kandangParam, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + value := uint(kandangID) + query.KandangID = &value + } + + result, err := u.DailyChecklistService.GetSummary(c, query) + if err != nil { + return err + } + + type summaryResponse struct { + PerformanceOverview []dto.DailyChecklistPerformanceOverviewDTO `json:"performance_overview"` + TrackingABK []dto.DailyChecklistSummaryDTO `json:"tracking_abk"` + } + + performanceMap := make(map[uint]*dto.DailyChecklistPerformanceOverviewDTO) + tracking := make([]dto.DailyChecklistSummaryDTO, len(result)) + + for i, summary := range result { + tracking[i] = dto.DailyChecklistSummaryDTO{ + EmployeeID: summary.EmployeeID, + EmployeeName: summary.EmployeeName, + KandangID: summary.KandangID, + KandangName: summary.KandangName, + TotalActivity: summary.TotalActivity, + ActivityDone: summary.ActivityDone, + ActivityLeft: summary.ActivityLeft, + CompletionRate: summary.CompletionRate, + LastActivity: summary.LastActivity, + } + + if _, ok := performanceMap[summary.EmployeeID]; !ok { + performanceMap[summary.EmployeeID] = &dto.DailyChecklistPerformanceOverviewDTO{ + EmployeeID: summary.EmployeeID, + EmployeeName: summary.EmployeeName, + } + } + + performanceMap[summary.EmployeeID].TotalActivity += summary.TotalActivity + performanceMap[summary.EmployeeID].ActivityDone += summary.ActivityDone + performanceMap[summary.EmployeeID].ActivityLeft += summary.ActivityLeft + } + + performance := make([]dto.DailyChecklistPerformanceOverviewDTO, 0, len(performanceMap)) + for _, v := range performanceMap { + performance = append(performance, *v) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist summary successfully", + Data: summaryResponse{ + PerformanceOverview: performance, + TrackingABK: tracking, + }, + }) +} + +func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error { + query := &validation.ReportQuery{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Month: c.QueryInt("bulan", 0), + Year: c.QueryInt("tahun", 0), + } + + parseUintParam := func(param string) (*uint, error) { + if param == "" { + return nil, nil + } + value, err := strconv.ParseUint(param, 10, 64) + if err != nil { + return nil, err + } + u := uint(value) + return &u, nil + } + + if val, err := parseUintParam(c.Query("area_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid area_id") + } else { + query.AreaID = val + } + + if val, err := parseUintParam(c.Query("location_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") + } else { + query.LocationID = val + } + + if val, err := parseUintParam(c.Query("kandang_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } else { + query.KandangID = val + } + + if val, err := parseUintParam(c.Query("employee_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee_id") + } else { + query.EmployeeID = val + } + + if val, err := parseUintParam(c.Query("phase_id", "")); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid phase_id") + } else { + query.PhaseID = val + } + + if query.Month == 0 || query.Year == 0 { + return fiber.NewError(fiber.StatusBadRequest, "bulan and tahun are required") + } + + result, totalResults, err := u.DailyChecklistService.GetReport(c, query) + withoutActivities := func(src map[string]int) map[string]int { + if src == nil { + return map[string]int{} + } + return src + } + + if err != nil { + return err + } + + responseData := make([]dto.DailyChecklistReportDTO, len(result)) + for i, item := range result { + responseData[i] = dto.DailyChecklistReportDTO{ + Area: dto.DailyChecklistReportEntityDTO{ + Id: item.AreaID, + Name: item.AreaName, + }, + Farm: dto.DailyChecklistReportEntityDTO{ + Id: item.LocationID, + Name: item.LocationName, + }, + Kandang: dto.DailyChecklistReportEntityDTO{ + Id: item.KandangID, + Name: item.KandangName, + }, + ABK: dto.DailyChecklistReportEntityDTO{ + Id: item.EmployeeID, + Name: item.EmployeeName, + }, + Phase: item.PhaseName, + DailyActivities: withoutActivities(item.DailyActivities), + Summary: dto.DailyChecklistReportSummaryDTO{ + TotalChecklist: item.Summary.TotalChecklist, + JumlahHariEfektif: item.Summary.JumlahHariEfektif, + AbkPercentage: item.Summary.AbkPercentage, + KandangPercentage: item.Summary.KandangPercentage, + Kategori: dto.DailyChecklistReportCategoryDTO{ + Kurang: item.Summary.Category.Kurang, + Cukup: item.Summary.Category.Cukup, + Baik: item.Summary.Category.Baik, + }, + }, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.DailyChecklistReportDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: responseData, + }) +} func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error { - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, err := u.DailyChecklistService.GetOne(c, uint(id)) + detail, err := u.DailyChecklistService.GetDetail(c, uint(id)) if err != nil { return err } @@ -71,7 +308,7 @@ func (u *DailyChecklistController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get dailyChecklist successfully", - Data: dto.ToDailyChecklistListDTO(*result), + Data: dto.ToDailyChecklistDetailDTO(detail.Checklist, detail.Phases, detail.Tasks, detail.AssignedEmployees, detail.TotalActivities, detail.Progress), }) } @@ -98,7 +335,7 @@ func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error { func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { req := new(validation.Update) - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { @@ -124,7 +361,7 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { } func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { - param := c.Params("id") + param := c.Params("idDailyChecklist") id, err := strconv.Atoi(param) if err != nil { @@ -217,6 +454,32 @@ func (u *DailyChecklistController) RemoveAssignment(c *fiber.Ctx) error { }) } +func (u *DailyChecklistController) GetPhaseByIdChecklist(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + phaseIDs, err := u.DailyChecklistService.GetChecklistPhaseIDs(c, uint(id)) + if err != nil { + return err + } + + responseData := make([]map[string]uint, len(phaseIDs)) + for i, phaseID := range phaseIDs { + responseData[i] = map[string]uint{"phase_id": phaseID} + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phases successfully", + Data: responseData, + }) +} + func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { checklistParam := c.Query("checklist_id", "") if checklistParam == "" { @@ -241,3 +504,21 @@ func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { Data: result, }) } + +func (u *DailyChecklistController) UpdateAssignment(c *fiber.Ctx) error { + req := new(validation.UpdateAssignment) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.UpdateAssignment(c, req); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Assignment updated successfully", + }) +} diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index 31953def..d133b76e 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -4,6 +4,10 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + employeeDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + phaseActivityDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/dto" + phasesDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -15,15 +19,100 @@ type DailyChecklistRelationDTO struct { } type DailyChecklistListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Category string `json:"category"` + Date time.Time `json:"date"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TotalPhase int `json:"total_phase"` + TotalActivity int `json:"total_activity"` + Progress int `json:"progress"` } type DailyChecklistDetailDTO struct { DailyChecklistListDTO + Phases []DailyChecklistPhaseDTO `json:"phases"` + Tasks []DailyChecklistActivityTaskDTO `json:"tasks"` + AssignedEmployees []employeeDTO.EmployeesRelationDTO `json:"assigned_employees"` + TotalActivity int `json:"total_activity"` + Progress float64 `json:"progress"` +} + +type DailyChecklistSummaryDTO struct { + EmployeeID uint `json:"employee_id"` + EmployeeName string `json:"employee_name"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + TotalActivity int `json:"total_activity"` + ActivityDone int `json:"activity_done"` + ActivityLeft int `json:"activity_left"` + CompletionRate int `json:"completion_rate"` + LastActivity *time.Time `json:"last_activity,omitempty"` +} + +type DailyChecklistPerformanceOverviewDTO struct { + EmployeeID uint `json:"employee_id"` + EmployeeName string `json:"employee_name"` + TotalActivity int `json:"total_activity"` + ActivityDone int `json:"activity_done"` + ActivityLeft int `json:"activity_left"` +} + +type DailyChecklistReportDTO struct { + Area DailyChecklistReportEntityDTO `json:"area"` + Farm DailyChecklistReportEntityDTO `json:"farm"` + Kandang DailyChecklistReportEntityDTO `json:"kandang"` + ABK DailyChecklistReportEntityDTO `json:"abk"` + Phase string `json:"phase"` + DailyActivities map[string]int `json:"daily_activities"` + Summary DailyChecklistReportSummaryDTO `json:"summary"` +} + +type DailyChecklistReportEntityDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type DailyChecklistReportSummaryDTO struct { + TotalChecklist int `json:"total_checklist"` + JumlahHariEfektif int `json:"jumlah_hari_efektif"` + AbkPercentage int `json:"abk_percentage"` + KandangPercentage int `json:"kandang_percentage"` + Kategori DailyChecklistReportCategoryDTO `json:"kategori"` +} + +type DailyChecklistReportCategoryDTO struct { + Kurang int `json:"kurang"` + Cukup int `json:"cukup"` + Baik int `json:"baik"` +} + +type DailyChecklistPhaseDTO struct { + Id uint `json:"id"` + PhaseId uint `json:"phase_id"` + Phase phasesDTO.PhasesListDTO `json:"phase"` +} + +type DailyChecklistActivityTaskDTO struct { + Id uint `json:"id"` + ChecklistId uint `json:"checklist_id"` + PhaseId uint `json:"phase_id"` + PhaseActivityId uint `json:"phase_activity_id"` + TimeType *string `json:"time_type"` + Notes *string `json:"notes"` + Phase phasesDTO.PhasesListDTO `json:"phase"` + PhaseActivity phaseActivityDTO.PhaseActivityListDTO `json:"phase_activity"` + Assignments []DailyChecklistAssignmentDTO `json:"assignments"` +} + +type DailyChecklistAssignmentDTO struct { + Employee employeeDTO.EmployeesRelationDTO `json:"employee"` + Checked bool `json:"checked"` + Note *string `json:"note"` } // === Mapper Functions === @@ -52,25 +141,92 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { name = *e.Name } + var status string + if e.Status != nil { + status = *e.Status + } + + var kandang *kandangDTO.KandangRelationDTO + if e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(e.Kandang) + kandang = &mapped + } + return DailyChecklistListDTO{ - Id: e.Id, - Name: name, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + Id: e.Id, + Name: name, + Status: status, + Category: e.Category, + Date: e.Date, + Kandang: kandang, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + TotalPhase: 0, + TotalActivity: 0, + Progress: 0, } } -func ToDailyChecklistListDTOs(e []entity.DailyChecklist) []DailyChecklistListDTO { - result := make([]DailyChecklistListDTO, len(e)) - for i, r := range e { - result[i] = ToDailyChecklistListDTO(r) +func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64) DailyChecklistDetailDTO { + phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) + for _, phase := range phases { + phaseDTOs = append(phaseDTOs, DailyChecklistPhaseDTO{ + Id: phase.Id, + PhaseId: phase.PhaseId, + Phase: phasesDTO.ToPhasesListDTO(phase.Phase), + }) + } + + taskDTOs := make([]DailyChecklistActivityTaskDTO, 0, len(tasks)) + for _, task := range tasks { + mappedAssignments := make([]DailyChecklistAssignmentDTO, 0, len(task.Assignments)) + for _, assignment := range task.Assignments { + if assignment.Employee.Id == 0 { + continue + } + mapped := DailyChecklistAssignmentDTO{ + Employee: employeeDTO.ToEmployeesRelationDTO(assignment.Employee), + Checked: assignment.Checked, + Note: assignment.Note, + } + mappedAssignments = append(mappedAssignments, mapped) + } + + phaseDTO := phasesDTO.PhasesListDTO{} + if task.Phase.Id != 0 { + phaseDTO = phasesDTO.ToPhasesListDTO(task.Phase) + } + + activityDTO := phaseActivityDTO.PhaseActivityListDTO{} + if task.PhaseActivity.Id != 0 { + activityDTO = phaseActivityDTO.ToPhaseActivityListDTO(task.PhaseActivity) + } + + taskDTOs = append(taskDTOs, DailyChecklistActivityTaskDTO{ + Id: task.Id, + ChecklistId: task.ChecklistId, + PhaseId: task.PhaseId, + PhaseActivityId: task.PhaseActivityId, + TimeType: task.TimeType, + Notes: task.Notes, + Phase: phaseDTO, + PhaseActivity: activityDTO, + Assignments: mappedAssignments, + }) + } + + assignedDTOs := make([]employeeDTO.EmployeesRelationDTO, 0, len(assignedEmployees)) + for _, emp := range assignedEmployees { + assignedDTOs = append(assignedDTOs, employeeDTO.ToEmployeesRelationDTO(emp)) } - return result -} -func ToDailyChecklistDetailDTO(e entity.DailyChecklist) DailyChecklistDetailDTO { return DailyChecklistDetailDTO{ - DailyChecklistListDTO: ToDailyChecklistListDTO(e), + DailyChecklistListDTO: ToDailyChecklistListDTO(checklist), + Phases: phaseDTOs, + Tasks: taskDTOs, + AssignedEmployees: assignedDTOs, + TotalActivity: totalActivities, + Progress: progress, } } diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index c8542671..0f6657c0 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -1,7 +1,7 @@ package dailyChecklists import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,23 +13,51 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. ctrl := controller.NewDailyChecklistController(s) route := v1.Group("/daily-checklists") - route.Use(m.Auth(u)) + // route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) + route.Get("/report", ctrl.GetReport) + + route.Get("/summary", ctrl.GetSummary) + + route.Get("/report", ctrl.GetReport) + + // create daily checklist route.Post("/", ctrl.CreateOne) + // get detail data daily checklist by id + route.Get("/relation/:idDailyChecklist", ctrl.GetOne) + + // get phases by daily checklist id + route.Get("/phase/:idDailyChecklist", ctrl.GetPhaseByIdChecklist) + // create task + /* + ketika add phase + */ route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) // create assigment + /* + ketika add ABK + */ route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + // remove assignment + /* + ketika remove ABK + */ route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) //get all tasks route.Get("/tasks", ctrl.GetAllTasks) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + // update assignment + /* + ketika check dan uncheck tugas oleh ABK + */ + route.Post("/assignment", ctrl.UpdateAssignment) + + route.Patch("/:idDailyChecklist", ctrl.UpdateOne) + route.Delete("/:idDailyChecklist", ctrl.DeleteOne) } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index bf5320e6..2ed15fad 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -2,6 +2,8 @@ package service import ( "errors" + "math" + "sort" "strconv" "strings" "time" @@ -20,7 +22,7 @@ import ( ) type DailyChecklistService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) + 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) @@ -29,6 +31,11 @@ type DailyChecklistService interface { 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 { @@ -38,6 +45,69 @@ type dailyChecklistService struct { PhaseRepo phaseRepo.PhasesRepository } +type DailyChecklistDetail struct { + Checklist entity.DailyChecklist + Phases []entity.DailyChecklistPhase + Tasks []entity.DailyChecklistActivityTask + AssignedEmployees []entity.Employee + TotalActivities int + Progress float64 +} + +type DailyChecklistListItem struct { + ID uint + Name *string + Date time.Time + Category string + Status *string + CreatedAt time.Time + UpdatedAt time.Time + Kandang entity.Kandang + 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]int + Summary DailyChecklistReportSummary +} + +type DailyChecklistReportSummary struct { + TotalChecklist int + JumlahHariEfektif int + AbkPercentage int + KandangPercentage int + Category DailyChecklistReportCategory +} + +type DailyChecklistReportCategory struct { + Kurang int + Cukup int + Baik int +} + func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService { return &dailyChecklistService{ Log: utils.Log, @@ -48,29 +118,163 @@ func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRep } func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { - return db + return db.Preload("Kandang") } -func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) { +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 - dailyChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") - } - return db.Order("created_at DESC").Order("updated_at DESC") - }) + db := s.Repository.DB().WithContext(c.Context()). + Table("daily_checklists dc"). + Joins("JOIN kandangs k ON k.id = dc.kandang_id") - if err != nil { + 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 != "" { + like := "%" + params.Search + "%" + db = db.Where("(k.name ILIKE ? OR dc.category ILIKE ?)", 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 + 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.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 } - return dailyChecklists, total, nil + + 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.Kandang) + if len(kandangIDs) > 0 { + var kandangs []entity.Kandang + 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, + 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) { @@ -85,6 +289,72 @@ func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyCheck 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) + } + + return &DailyChecklistDetail{ + Checklist: *checklist, + Phases: phases, + Tasks: tasks, + AssignedEmployees: assignedEmployees, + TotalActivities: totalActivities, + Progress: progress, + }, 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 @@ -122,14 +392,12 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return nil, err } - updateBody := make(map[string]any) - - if req.Name != nil { - updateBody["name"] = *req.Name + updateBody := map[string]any{ + "status": req.Status, } - if len(updateBody) == 0 { - return s.GetOne(c, id) + if req.RejectReason != nil { + updateBody["reject_reason"] = *req.RejectReason } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { @@ -296,6 +564,71 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit 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.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 + } + + 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)) @@ -355,6 +688,32 @@ func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint { } 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 @@ -408,3 +767,413 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio 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"). + Joins("JOIN kandangs k ON k.id = d.kandang_id"). + Joins("JOIN employees e ON e.id = a.employee_id"). + Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED") + + 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 + } + + 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"). + Joins("JOIN employees e ON e.id = dca.employee_id"). + Joins("JOIN kandangs 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) + + 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") + } + + var total int64 + groupedForCount := buildGroupedQuery() + if err := s.Repository.DB().WithContext(c.Context()). + Table("(?) AS grouped", groupedForCount). + Count(&total).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 + } + + rows := make([]reportRow, 0) + if err := buildGroupedQuery(). + Order("a.name, loc.name, k.name, e.name"). + Offset(offset). + Limit(params.Limit). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to fetch report data: %+v", err) + return nil, 0, err + } + + if len(rows) == 0 { + return []DailyChecklistReportItem{}, total, 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 rows { + 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 + } + + items := make([]DailyChecklistReportItem, len(rows)) + for i, row := range rows { + 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]int, 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)) + } + + items[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, + }, + } + } + + return items, total, nil +} diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index ba81fd0d..a42d424a 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -8,13 +8,18 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Status string `json:"status" validate:"required"` + RejectReason *string `json:"reject_reason" validate:"required"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + DateFrom string `query:"date_from" validate:"omitempty"` + DateTo string `query:"date_to" validate:"omitempty"` + Status string `query:"status" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` } type AssignPhases struct { @@ -24,3 +29,29 @@ type AssignPhases struct { type AssignTask struct { EmployeeIDs string `json:"employee_ids" validate:"required"` } + +type UpdateAssignment struct { + TaskID uint `json:"task_id" validate:"required"` + EmployeeID uint `json:"employee_id" validate:"required"` + Checked *bool `json:"checked,omitempty"` + Note *string `json:"note,omitempty"` +} + +type SummaryQuery struct { + DateFrom string `query:"date_from" validate:"required"` + DateTo string `query:"date_to" validate:"required"` + Category string `query:"category" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` +} + +type ReportQuery struct { + Page int `query:"page" validate:"required,number,min=1,gt=0"` + Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` + Month int `query:"bulan" validate:"required,number,min=1,max=12"` + Year int `query:"tahun" validate:"required,number,min=1900"` + AreaID *uint `query:"area_id" validate:"omitempty"` + LocationID *uint `query:"location_id" validate:"omitempty"` + KandangID *uint `query:"kandang_id" validate:"omitempty"` + EmployeeID *uint `query:"employee_id" validate:"omitempty"` + PhaseID *uint `query:"phase_id" validate:"omitempty"` +} diff --git a/internal/modules/master/config-checklists/controllers/config-checklist.controller.go b/internal/modules/master/config-checklists/controllers/config-checklist.controller.go new file mode 100644 index 00000000..362f1aaa --- /dev/null +++ b/internal/modules/master/config-checklists/controllers/config-checklist.controller.go @@ -0,0 +1,144 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ConfigChecklistController struct { + ConfigChecklistService service.ConfigChecklistService +} + +func NewConfigChecklistController(configChecklistService service.ConfigChecklistService) *ConfigChecklistController { + return &ConfigChecklistController{ + ConfigChecklistService: configChecklistService, + } +} + +func (u *ConfigChecklistController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ConfigChecklistService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ConfigChecklistListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all configChecklists successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToConfigChecklistListDTOs(result), + }) +} + +func (u *ConfigChecklistController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ConfigChecklistService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ConfigChecklistService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ConfigChecklistService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update configChecklist successfully", + Data: dto.ToConfigChecklistListDTO(*result), + }) +} + +func (u *ConfigChecklistController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ConfigChecklistService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete configChecklist successfully", + }) +} diff --git a/internal/modules/master/config-checklists/dto/config-checklist.dto.go b/internal/modules/master/config-checklists/dto/config-checklist.dto.go new file mode 100644 index 00000000..d6af71aa --- /dev/null +++ b/internal/modules/master/config-checklists/dto/config-checklist.dto.go @@ -0,0 +1,61 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type ConfigChecklistRelationDTO struct { + Id uint `json:"id"` + Date time.Time `json:"date"` +} + +type ConfigChecklistListDTO struct { + Id uint `json:"id"` + Date time.Time `json:"date"` + PercentageThresholdBad int `json:"percentage_threshold_bad"` + PercentageThresholdEnough int `json:"percentage_threshold_enough"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConfigChecklistDetailDTO struct { + ConfigChecklistListDTO +} + +// === Mapper Functions === + +func ToConfigChecklistRelationDTO(e entity.ConfigChecklist) ConfigChecklistRelationDTO { + return ConfigChecklistRelationDTO{ + Id: e.Id, + Date: e.Date, + } +} + +func ToConfigChecklistListDTO(e entity.ConfigChecklist) ConfigChecklistListDTO { + return ConfigChecklistListDTO{ + Id: e.Id, + Date: e.Date, + PercentageThresholdBad: e.PercentageThresholdBad, + PercentageThresholdEnough: e.PercentageThresholdEnough, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToConfigChecklistListDTOs(e []entity.ConfigChecklist) []ConfigChecklistListDTO { + result := make([]ConfigChecklistListDTO, len(e)) + for i, r := range e { + result[i] = ToConfigChecklistListDTO(r) + } + return result +} + +func ToConfigChecklistDetailDTO(e entity.ConfigChecklist) ConfigChecklistDetailDTO { + return ConfigChecklistDetailDTO{ + ConfigChecklistListDTO: ToConfigChecklistListDTO(e), + } +} diff --git a/internal/modules/master/config-checklists/module.go b/internal/modules/master/config-checklists/module.go new file mode 100644 index 00000000..711a91f3 --- /dev/null +++ b/internal/modules/master/config-checklists/module.go @@ -0,0 +1,25 @@ +package configChecklists + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rConfigChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/repositories" + sConfigChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ConfigChecklistModule struct{} + +func (ConfigChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + configChecklistRepo := rConfigChecklist.NewConfigChecklistRepository(db) + userRepo := rUser.NewUserRepository(db) + + configChecklistService := sConfigChecklist.NewConfigChecklistService(configChecklistRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ConfigChecklistRoutes(router, userService, configChecklistService) +} diff --git a/internal/modules/master/config-checklists/repositories/config-checklist.repository.go b/internal/modules/master/config-checklists/repositories/config-checklist.repository.go new file mode 100644 index 00000000..5bbf75ca --- /dev/null +++ b/internal/modules/master/config-checklists/repositories/config-checklist.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type ConfigChecklistRepository interface { + repository.BaseRepository[entity.ConfigChecklist] +} + +type ConfigChecklistRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ConfigChecklist] +} + +func NewConfigChecklistRepository(db *gorm.DB) ConfigChecklistRepository { + return &ConfigChecklistRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ConfigChecklist](db), + } +} diff --git a/internal/modules/master/config-checklists/route.go b/internal/modules/master/config-checklists/route.go new file mode 100644 index 00000000..1b590067 --- /dev/null +++ b/internal/modules/master/config-checklists/route.go @@ -0,0 +1,23 @@ +package configChecklists + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/controllers" + configChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ConfigChecklistRoutes(v1 fiber.Router, u user.UserService, s configChecklist.ConfigChecklistService) { + ctrl := controller.NewConfigChecklistController(s) + + route := v1.Group("/config-checklists") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/config-checklists/services/config-checklist.service.go b/internal/modules/master/config-checklists/services/config-checklist.service.go new file mode 100644 index 00000000..0c96e3d5 --- /dev/null +++ b/internal/modules/master/config-checklists/services/config-checklist.service.go @@ -0,0 +1,146 @@ +package service + +import ( + "errors" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ConfigChecklistService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ConfigChecklist, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ConfigChecklist, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ConfigChecklist, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ConfigChecklist, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type configChecklistService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ConfigChecklistRepository +} + +func NewConfigChecklistService(repo repository.ConfigChecklistRepository, validate *validator.Validate) ConfigChecklistService { + return &configChecklistService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s configChecklistService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s configChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ConfigChecklist, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + configChecklists, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + return db.Order("date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get configChecklists: %+v", err) + return nil, 0, err + } + return configChecklists, total, nil +} + +func (s configChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.ConfigChecklist, error) { + configChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ConfigChecklist not found") + } + if err != nil { + s.Log.Errorf("Failed get configChecklist by id: %+v", err) + return nil, err + } + return configChecklist, nil +} + +func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ConfigChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + + createBody := &entity.ConfigChecklist{ + Date: date, + PercentageThresholdBad: req.PercentageThresholdBad, + PercentageThresholdEnough: req.PercentageThresholdEnough, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create configChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ConfigChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Date != nil { + date, err := time.Parse("2006-01-02", *req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + updateBody["date"] = date + } + + if req.PercentageThresholdBad != nil { + updateBody["percentage_threshold_bad"] = *req.PercentageThresholdBad + } + + if req.PercentageThresholdEnough != nil { + updateBody["percentage_threshold_enough"] = *req.PercentageThresholdEnough + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ConfigChecklist not found") + } + s.Log.Errorf("Failed to update configChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s configChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ConfigChecklist not found") + } + s.Log.Errorf("Failed to delete configChecklist: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/config-checklists/validations/config-checklist.validation.go b/internal/modules/master/config-checklists/validations/config-checklist.validation.go new file mode 100644 index 00000000..10f477b7 --- /dev/null +++ b/internal/modules/master/config-checklists/validations/config-checklist.validation.go @@ -0,0 +1,19 @@ +package validation + +type Create struct { + Date string `json:"date" validate:"required"` + PercentageThresholdBad int `json:"percentage_threshold_bad" validate:"required"` + PercentageThresholdEnough int `json:"percentage_threshold_enough" validate:"required"` +} + +type Update struct { + Date *string `json:"date,omitempty" validate:"omitempty"` + PercentageThresholdBad *int `json:"percentage_threshold_bad,omitempty" validate:"omitempty"` + PercentageThresholdEnough *int `json:"percentage_threshold_enough,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go index 2e2cc879..83608071 100644 --- a/internal/modules/master/employees/validations/employees.validation.go +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -14,7 +14,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` KandangId *uint `query:"kandang_id" validate:"omitempty"` IsActive *bool `query:"is_active" validate:"omitempty"` diff --git a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go index 455ff1e4..3cbc68f2 100644 --- a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go +++ b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go @@ -28,20 +28,12 @@ func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), } + query.PhaseIDs = c.Query("phase_ids", "") if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } - if phaseParam := c.Query("phase_id", ""); phaseParam != "" { - id, err := strconv.Atoi(phaseParam) - if err != nil || id <= 0 { - return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id") - } - temp := uint(id) - query.PhaseId = &temp - } - result, totalResults, err := u.PhaseActivityService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 3426eab4..24b8272e 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strconv" "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,7 +42,8 @@ func NewPhaseActivityService(repo repository.PhaseActivityRepository, phaseRepo } func (s phaseActivityService) withRelations(db *gorm.DB) *gorm.DB { - return db + return db.Joins("JOIN phases ON phases.id = phase_activities.phase_id"). + Where("phases.deleted_at IS NULL") } func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) { @@ -56,8 +58,11 @@ func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([] if params.Search != "" { db = db.Where("name LIKE ?", "%"+params.Search+"%") } - if params.PhaseId != nil { - db = db.Where("phase_id = ?", *params.PhaseId) + if params.PhaseIDs != "" { + ids := parseIDs(params.PhaseIDs) + if len(ids) > 0 { + db = db.Where("phase_id IN ?", ids) + } } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -165,3 +170,18 @@ func (s phaseActivityService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +func parseIDs(raw string) []uint { + parts := strings.Split(raw, ",") + results := make([]uint, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + if n, err := strconv.ParseUint(value, 10, 64); err == nil { + results = append(results, uint(n)) + } + } + return results +} diff --git a/internal/modules/master/phase-activities/validations/phase-activity.validation.go b/internal/modules/master/phase-activities/validations/phase-activity.validation.go index a2ab8e1b..54186315 100644 --- a/internal/modules/master/phase-activities/validations/phase-activity.validation.go +++ b/internal/modules/master/phase-activities/validations/phase-activity.validation.go @@ -14,8 +14,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` - PhaseId *uint `query:"phase_id" validate:"omitempty"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PhaseIDs string `query:"phase_ids" validate:"omitempty"` } diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index f9bc7b13..06ba1ae3 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -24,6 +24,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + configChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists" // MODULE IMPORTS ) @@ -48,6 +49,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida employeess.EmployeesModule{}, phasess.PhasesModule{}, phaseActivitys.PhaseActivityModule{}, + configChecklists.ConfigChecklistModule{}, // MODULE REGISTRY } diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index e18e7dce..70372ece 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -5,6 +5,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" @@ -40,6 +41,13 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + var charts map[uint]utypes.UniformityChartData + if query.WithChart { + charts, err = u.UniformityService.MapCharts(c, result) + if err != nil { + return err + } + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -51,13 +59,9 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { Limit: query.Limit, TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, - Filters: fiber.Map{ - "location_id": "", - "project_flock_id": "", - "status": "Pengajuan", - }, + Filters: dto.BuildUniformityFilters(query), }, - Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents, charts), }) } @@ -73,7 +77,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { } withDetails := c.QueryBool("with_details", false) - calculation := service.UniformityCalculation{} + calculation := utypes.UniformityCalculation{} var document *entity.Document var documentURL string var meanWeight float64 @@ -87,7 +91,7 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { return err } } else { - calculation = service.UniformityCalculation{ + calculation = utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: meanWeight, MeanDown: result.MeanDown, @@ -229,7 +233,7 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { } } - calculation := service.UniformityCalculation{ + calculation := utypes.UniformityCalculation{ ChickQtyOfWeight: result.ChickQtyOfWeight, MeanWeight: math.Round(result.MeanUp / 1.10), MeanDown: result.MeanDown, diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index af401a54..3db819bf 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -5,7 +5,11 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/gofiber/fiber/v2" ) type UniformitySamplingDTO struct { @@ -49,13 +53,13 @@ type UniformityInfoDTO struct { } type UniformityDetailDTO struct { - Id uint `json:"id"` - InfoUmum UniformityInfoDTO `json:"info_umum"` - Sampling UniformitySamplingDTO `json:"sampling"` - Result UniformityResultDTO `json:"result"` - Standard *UniformityStandardDTO `json:"standard"` + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` - UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } type UniformityListDTO struct { @@ -76,6 +80,7 @@ type UniformityListDTO struct { MeanDown float64 `json:"mean_down"` StandardMeanWeight *float64 `json:"standard_mean_weight"` StandardUniformity *float64 `json:"standard_uniformity"` + ChartData *utypes.UniformityChartData `json:"chart_data,omitempty"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } @@ -87,7 +92,7 @@ func NewDocumentForResponse(name string) *entity.Document { return &entity.Document{Name: name} } -func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { +func ToUniformityVerificationDTO(calc utypes.UniformityCalculation) UniformityVerificationDTO { return UniformityVerificationDTO{ Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), @@ -97,7 +102,7 @@ func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityV func ToUniformityDetailDTO( entityData entity.ProjectFlockKandangUniformity, - calc service.UniformityCalculation, + calc utypes.UniformityCalculation, document *entity.Document, documentURL string, standard *UniformityStandardDTO, @@ -171,8 +176,9 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor func ToUniformityListDTOsWithStandard( items []entity.ProjectFlockKandangUniformity, - standards map[uint]service.UniformityStandard, + standards map[uint]utypes.UniformityStandard, documentNames map[uint]string, + charts map[uint]utypes.UniformityChartData, ) []UniformityListDTO { result := ToUniformityListDTOs(items) if len(result) == 0 || len(standards) == 0 { @@ -180,6 +186,10 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } @@ -192,11 +202,15 @@ func ToUniformityListDTOsWithStandard( if name, ok := documentNames[result[i].Id]; ok { result[i].FileName = name } + if chart, ok := charts[result[i].Id]; ok { + chartCopy := chart + result[i].ChartData = &chartCopy + } } return result } -func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { +func toUniformitySamplingDTO(calc utypes.UniformityCalculation) UniformitySamplingDTO { return UniformitySamplingDTO{ ChickQtyOfWeight: calc.ChickQtyOfWeight, MeanWeight: calc.MeanWeight, @@ -205,7 +219,7 @@ func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySampl } } -func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { +func toUniformityResultDTO(calc utypes.UniformityCalculation) UniformityResultDTO { return UniformityResultDTO{ UniformQty: calc.UniformQty, OutsideQty: calc.OutsideQty, @@ -214,7 +228,7 @@ func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultD } } -func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { +func toUniformityDetailItemsDTO(calc utypes.UniformityCalculation) []UniformityDetailItemDTO { result := make([]UniformityDetailItemDTO, len(calc.Details)) for i, item := range calc.Details { result[i] = UniformityDetailItemDTO{ @@ -254,5 +268,18 @@ func formatUniformityDate(date *time.Time) string { if date == nil || date.IsZero() { return "" } - return date.Format("2006-01-02") + return utils.FormatDate(*date) +} + +func BuildUniformityFilters(query *validation.Query) fiber.Map { + if query == nil { + return fiber.Map{} + } + return fiber.Map{ + "project_flock_kandang_id": query.ProjectFlockKandangId, + "start_date": query.StartDate, + "end_date": query.EndDate, + "with_chart": query.WithChart, + "status": "Pengajuan", + } } diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go index 241dea49..9641c650 100644 --- a/internal/modules/production/uniformities/repositories/uniformity.repository.go +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -2,14 +2,18 @@ package repository import ( "context" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gorm.io/gorm" ) type UniformityRepository interface { repository.BaseRepository[entity.ProjectFlockKandangUniformity] + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + WithDefaultRelations() func(*gorm.DB) *gorm.DB DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } @@ -23,6 +27,46 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository { } } +func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + return r.applyQueryFilters(r.WithDefaultRelations()(db), params) + }) +} + +func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") + } +} + +func (r *UniformityRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { + if params == nil { + return db + } + + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + + startDateValue, endDateValue, err := validation.ParseDateRange(params.StartDate, params.EndDate) + if err == nil { + if startDateValue != nil && endDateValue != nil { + db = db.Where("uniform_date >= ? AND uniform_date < ?", *startDateValue, endDateValue.Add(24*time.Hour)) + } else if startDateValue != nil { + db = db.Where("uniform_date >= ?", *startDateValue) + } else if endDateValue != nil { + db = db.Where("uniform_date < ?", endDateValue.Add(24*time.Hour)) + } + } + + return db.Order("uniform_date DESC").Order("id DESC") +} + func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { if len(projectFlockKandangIDs) == 0 { return nil diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go deleted file mode 100644 index 4e87f0cc..00000000 --- a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go +++ /dev/null @@ -1,200 +0,0 @@ -package service - -import ( - "io" - "mime/multipart" - "strconv" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/xuri/excelize/v2" -) - -type BodyWeightExcelRow struct { - No int `json:"no"` - Weight float64 `json:"weight"` - Range string `json:"range,omitempty"` -} - -func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { - if file == nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") - } - - reader, err := file.Open() - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") - } - defer reader.Close() - - rows, err := parseBodyWeightExcelReader(reader) - if err != nil { - return nil, err - } - - return rows, nil -} - -func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { - xlsx, err := excelize.OpenReader(reader) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") - } - defer func() { - _ = xlsx.Close() - }() - - sheets := xlsx.GetSheetList() - if len(sheets) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") - } - - sheetName := sheets[0] - if len(sheets) > 1 { - sheetName = sheets[1] - } - - rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") - } - - return parseBodyWeightRows(rows) -} - -func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { - headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) - if headerRowIdx < 0 || bwCol < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") - } - - result := make([]BodyWeightExcelRow, 0) - lastNo := 0 - - for i := headerRowIdx + 1; i < len(rows); i++ { - row := rows[i] - weightStr := cellAt(row, bwCol) - weightVal, ok := parseNumber(weightStr) - if !ok { - continue - } - - noVal := 0 - if noCol >= 0 { - if parsed, ok := parseNumber(cellAt(row, noCol)); ok { - noVal = int(parsed) - } - } - if noVal <= 0 { - noVal = lastNo + 1 - } - if noVal > lastNo { - lastNo = noVal - } - - rangeVal := "" - if rangeCol >= 0 { - rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) - } - - rowPayload := BodyWeightExcelRow{ - No: noVal, - Weight: weightVal, - Range: rangeVal, - } - if rowPayload.No <= 0 || rowPayload.Weight <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") - } - - result = append(result, rowPayload) - } - - if len(result) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - return result, nil -} - -func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { - rowIdx = -1 - noCol = -1 - bwCol = -1 - rangeCol = -1 - - for i, row := range rows { - tempNo := -1 - tempBW := -1 - tempRange := -1 - for j, cell := range row { - label := normalizeHeader(cell) - switch label { - case "no": - tempNo = j - case "bw": - tempBW = j - case "outsiderange": - tempRange = j - default: - if strings.HasPrefix(label, "bw") { - tempBW = j - } else if strings.HasPrefix(label, "no") { - tempNo = j - } else if strings.Contains(label, "range") { - tempRange = j - } - } - } - if tempBW >= 0 { - rowIdx = i - bwCol = tempBW - noCol = tempNo - rangeCol = tempRange - break - } - } - - return rowIdx, noCol, bwCol, rangeCol -} - -func cellAt(row []string, idx int) string { - if idx < 0 || idx >= len(row) { - return "" - } - return strings.TrimSpace(row[idx]) -} - -func normalizeHeader(value string) string { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - return "" - } - var b strings.Builder - for _, r := range trimmed { - if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - return b.String() -} - -func parseNumber(value string) (float64, bool) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return 0, false - } - - if strings.Contains(trimmed, ",") { - if strings.Contains(trimmed, ".") { - trimmed = strings.ReplaceAll(trimmed, ",", "") - } else { - trimmed = strings.ReplaceAll(trimmed, ",", ".") - } - } - - parsed, err := strconv.ParseFloat(trimmed, 64) - if err != nil { - return 0, false - } - return parsed, true -} diff --git a/internal/modules/production/uniformities/services/uniformity.calculate.go b/internal/modules/production/uniformities/services/uniformity.calculate.go new file mode 100644 index 00000000..24eb107a --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.calculate.go @@ -0,0 +1,433 @@ +package service + +import ( + "fmt" + "io" + "math" + "mime/multipart" + "strconv" + "strings" + + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func computeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]utypes.UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, utypes.UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return utypes.UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + if details[i].Weight > meanUpThreshold || details[i].Weight < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + + var deviationSum float64 + for _, w := range weights { + deviation := w - mean + deviationSum += deviation * deviation + } + stdDev := math.Sqrt(deviationSum / total) + + cv := 0.0 + if mean != 0 { + cv = (stdDev / mean) * 100 + } + + outsideCount := total - uniformCount + uniformity := 0.0 + if total > 0 { + uniformity = (uniformCount / total) * 100 + } + + return utypes.UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(meanDownThreshold, 0), + MeanUp: roundToPrecision(meanUpThreshold, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func extractWeights(rows []BodyWeightExcelRow) []float64 { + weights := make([]float64, 0, len(rows)) + for _, row := range rows { + if row.Weight <= 0 { + continue + } + weights = append(weights, row.Weight) + } + return weights +} + +func buildChartWeekSummary(weights []float64) utypes.UniformityChartWeek { + if len(weights) == 0 { + return utypes.UniformityChartWeek{ + HasData: false, + WeightDistribution: []utypes.UniformityChartRange{}, + } + } + + minWeight := weights[0] + maxWeight := weights[0] + var sum float64 + for _, w := range weights { + sum += w + if w < minWeight { + minWeight = w + } + if w > maxWeight { + maxWeight = w + } + } + mean := sum / float64(len(weights)) + + idealMin := roundToPrecision(mean*0.90, 0) + idealMax := roundToPrecision(mean*1.10, 0) + idealCount := 0.0 + for _, w := range weights { + if w >= idealMin && w <= idealMax { + idealCount++ + } + } + + const bucketSize = 5.0 + start := math.Floor(minWeight/bucketSize) * bucketSize + end := math.Floor(maxWeight/bucketSize) * bucketSize + + distribution := make([]utypes.UniformityChartRange, 0) + for bucket := start; bucket <= end; bucket += bucketSize { + minBucket := bucket + maxBucket := bucket + bucketSize - 1 + count := 0.0 + bucketWeights := make([]float64, 0) + idealWeights := make([]float64, 0) + outsideWeights := make([]float64, 0) + for _, w := range weights { + if w >= minBucket && w < minBucket+bucketSize { + count++ + bucketWeights = append(bucketWeights, w) + if w >= idealMin && w <= idealMax { + idealWeights = append(idealWeights, w) + } else { + outsideWeights = append(outsideWeights, w) + } + } + } + idealRangeLabel := rangeFromValues(idealWeights) + outsideRangeLabel := rangeFromValues(outsideWeights) + isIdealRange := idealRangeLabel != "" + distribution = append(distribution, utypes.UniformityChartRange{ + Range: fmt.Sprintf("%d-%d", int(minBucket), int(maxBucket)), + MinWeight: minBucket, + MaxWeight: maxBucket, + BirdCount: count, + IsIdealRange: isIdealRange, + IdealRange: idealRangeLabel, + OutsideRange: outsideRangeLabel, + }) + } + + statistics := &utypes.UniformityChartStatistics{ + MinWeight: roundToPrecision(minWeight, 0), + MaxWeight: roundToPrecision(maxWeight, 0), + AverageWeight: roundToPrecision(mean, 1), + TotalBirdsMeasured: float64(len(weights)), + } + + return utypes.UniformityChartWeek{ + HasData: true, + WeightDistribution: distribution, + IdealRange: &utypes.UniformityChartIdealRange{ + MinWeight: idealMin, + MaxWeight: idealMax, + TotalIdealBirds: idealCount, + }, + Statistics: statistics, + } +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} + +func rangeFromValues(values []float64) string { + if len(values) == 0 { + return "" + } + minValue := values[0] + maxValue := values[0] + for _, v := range values[1:] { + if v < minValue { + minValue = v + } + if v > maxValue { + maxValue = v + } + } + return formatRange(minValue, maxValue) +} + +func formatRange(minValue, maxValue float64) string { + minInt := int(math.Round(minValue)) + maxInt := int(math.Round(maxValue)) + if minInt == maxInt { + return fmt.Sprintf("%d", minInt) + } + return fmt.Sprintf("%d-%d", minInt, maxInt) +} \ No newline at end of file diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 747eb965..cde86694 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -2,11 +2,12 @@ package service import ( "context" + "encoding/json" "errors" "fmt" - "math" "mime/multipart" "net/http" + "sort" "strings" "time" @@ -17,6 +18,7 @@ import ( rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + utypes "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/types" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -31,17 +33,18 @@ type UniformityService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) - GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) - MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) + MapCharts(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) - ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) - CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) } type uniformityService struct { @@ -79,29 +82,13 @@ func NewUniformityService( } } -func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("ProjectFlockKandang.ProjectFlock.Location"). - Preload("ProjectFlockKandang.Kandang.Location") -} - func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit - - uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) - } - if params.Week != 0 { - db = db.Where("week = ?", params.Week) - } - return db.Order("uniform_date DESC").Order("id DESC") - }) + uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get uniformitys: %+v", err) @@ -114,7 +101,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent } func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { - uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") } @@ -132,14 +119,14 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo return s.GetOne(c, id) } -func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if uniformity == nil { return nil, nil } return s.resolveUniformityStandard(c.Context(), *uniformity) } -func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityStandard, error) { if len(items) == 0 { return nil, nil } @@ -149,7 +136,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc categoryStandard := make(map[string]*entity.ProductionStandard) detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) - result := make(map[uint]UniformityStandard, len(items)) + result := make(map[uint]utypes.UniformityStandard, len(items)) for _, item := range items { if item.Id == 0 { @@ -180,7 +167,7 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc if !ok { continue } - standardDTO := UniformityStandard{ + standardDTO := utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), } @@ -190,6 +177,109 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc return result, nil } +func (s uniformityService) MapCharts(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]utypes.UniformityChartData, error) { + if len(items) == 0 { + return nil, nil + } + + grouped := make(map[uint][]entity.ProjectFlockKandangUniformity) + for _, item := range items { + if item.ProjectFlockKandangId == 0 { + continue + } + grouped[item.ProjectFlockKandangId] = append(grouped[item.ProjectFlockKandangId], item) + } + + if len(grouped) == 0 { + return nil, nil + } + + result := make(map[uint]utypes.UniformityChartData, len(items)) + + for _, group := range grouped { + allWeeks := make(map[int]utypes.UniformityChartWeek) + weekOrder := make([]int, 0, len(group)) + weekSeen := make(map[int]struct{}, len(group)) + weeksWithData := 0 + gaugeWeeks := make([]utypes.UniformityChartGaugeWeek, 0, len(group)) + + for _, item := range group { + if item.Week == 0 { + continue + } + var weekSummary utypes.UniformityChartWeek + if len(item.ChartData) > 0 { + if err := json.Unmarshal(item.ChartData, &weekSummary); err != nil { + return nil, err + } + } + if weekSummary.WeightDistribution == nil { + weekSummary.WeightDistribution = []utypes.UniformityChartRange{} + } + if !weekSummary.HasData && item.ChickQtyOfWeight > 0 { + weekSummary.HasData = true + } + if weekSummary.HasData { + weeksWithData++ + } + allWeeks[item.Week] = weekSummary + + if _, ok := weekSeen[item.Week]; !ok { + weekSeen[item.Week] = struct{}{} + weekOrder = append(weekOrder, item.Week) + } + + hasData := item.ChickQtyOfWeight > 0 + gaugeWeeks = append(gaugeWeeks, utypes.UniformityChartGaugeWeek{ + Week: item.Week, + UniformityPercent: item.Uniformity, + IdealCount: item.UniformQty, + OutsideIdealCount: item.NotUniformQty, + TotalCount: item.ChickQtyOfWeight, + HasData: hasData, + }) + } + + sort.Ints(weekOrder) + sort.Slice(gaugeWeeks, func(i, j int) bool { + return gaugeWeeks[i].Week < gaugeWeeks[j].Week + }) + + weekIndex := make(map[int]int, len(weekOrder)) + for idx, week := range weekOrder { + weekIndex[week] = idx + } + + totalWeeks := len(weekOrder) + for _, item := range group { + if item.Id == 0 || item.Week == 0 { + continue + } + currentIndex := weekIndex[item.Week] + chart := utypes.UniformityChartData{ + BarChart: utypes.UniformityChartBar{ + CurrentWeek: item.Week, + AllWeeks: allWeeks, + }, + GaugeChart: utypes.UniformityChartGauge{ + CurrentWeek: item.Week, + AvailableWeeks: gaugeWeeks, + WeekInfo: utypes.UniformityChartWeekInfo{ + TotalWeeks: totalWeeks, + WeeksWithData: weeksWithData, + CurrentWeekIndex: currentIndex, + HasPrevWeek: currentIndex > 0, + HasNextWeek: currentIndex < totalWeeks-1, + }, + }, + } + result[item.Id] = chart + } + } + + return result, nil +} + func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { if s.DocumentSvc == nil || len(items) == 0 { return map[uint]string{}, nil @@ -252,6 +342,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -267,6 +362,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ProjectFlockKandangId: req.ProjectFlockKandangId, UniformQty: calculation.UniformQty, NotUniformQty: calculation.OutsideQty, + ChartData: chartJSON, UniformDate: &uniformDate, CreatedBy: actorID, } @@ -307,7 +403,12 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file }, }) if err != nil { - s.rollbackUniformityCreate(c.Context(), createBody.Id) + if errDelete := s.ApprovalRepo.DeleteByTarget(c.Context(), utils.ApprovalWorkflowUniformity.String(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity approvals for %d", createBody.Id) + } + if errDelete := s.Repository.DeleteOne(c.Context(), createBody.Id); errDelete != nil { + s.Log.WithError(errDelete).Warnf("Failed to rollback uniformity %d", createBody.Id) + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") } } @@ -391,6 +492,11 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if err != nil { return nil, err } + chartSummary := buildChartWeekSummary(extractWeights(rows)) + chartJSON, err := json.Marshal(chartSummary) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to build chart data") + } updateBody["uniformity"] = calculation.Uniformity updateBody["cv"] = calculation.Cv @@ -399,6 +505,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui updateBody["mean_down"] = calculation.MeanDown updateBody["uniform_qty"] = calculation.UniformQty updateBody["not_uniform_qty"] = calculation.OutsideQty + updateBody["chart_data"] = chartJSON } if len(updateBody) == 0 { @@ -590,30 +697,7 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en return results, nil } -type UniformityDetailItem struct { - Id int - Weight float64 - Range string -} - -type UniformityCalculation struct { - ChickQtyOfWeight float64 - MeanWeight float64 - MeanDown float64 - MeanUp float64 - UniformQty float64 - OutsideQty float64 - Uniformity float64 - Cv float64 - Details []UniformityDetailItem -} - -type UniformityStandard struct { - MeanWeight *float64 - Uniformity *float64 -} - -func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (utypes.UniformityCalculation, error) { return computeUniformity(rows) } @@ -621,37 +705,37 @@ func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*en return s.fetchUniformityDocument(c.Context(), uniformityID, true) } -func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (utypes.UniformityCalculation, *entity.Document, string, error) { document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } if document == nil || url == "" { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } resp, err := http.DefaultClient.Do(req) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + return utypes.UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") } rows, err := parseBodyWeightExcelReader(resp.Body) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } calculation, err := computeUniformity(rows) if err != nil { - return UniformityCalculation{}, nil, "", err + return utypes.UniformityCalculation{}, nil, "", err } return calculation, document, url, nil @@ -783,7 +867,7 @@ func (s *uniformityService) attachLatestApproval(ctx context.Context, item *enti return nil } -func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) { if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { return nil, nil } @@ -801,7 +885,7 @@ func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item return nil, err } - return &UniformityStandard{ + return &utypes.UniformityStandard{ MeanWeight: cloneFloat64(detail.TargetMeanBw), Uniformity: float64Ptr(detail.MinUniformity), }, nil @@ -858,22 +942,6 @@ func float64Ptr(value float64) *float64 { return © } -func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { - if uniformityID == 0 { - return - } - - if s.ApprovalRepo != nil { - if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) - } - } - - if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { - s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) - } -} - func uniqueUintSlice(values []uint) []uint { if len(values) == 0 { return nil @@ -893,114 +961,3 @@ func uniqueUintSlice(values []uint) []uint { } return result } - -func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { - weights := make([]float64, 0, len(rows)) - details := make([]UniformityDetailItem, 0, len(rows)) - hasRangeLabels := false - for idx, row := range rows { - if row.Weight <= 0 { - continue - } - id := row.No - if id <= 0 { - id = idx + 1 - } - weights = append(weights, row.Weight) - rangeLabel := strings.TrimSpace(row.Range) - if rangeLabel != "" { - upper := strings.ToUpper(rangeLabel) - if upper == "HIGH" || upper == "LOW" { - hasRangeLabels = true - } - rangeLabel = upper - } - details = append(details, UniformityDetailItem{ - Id: id, - Weight: row.Weight, - Range: rangeLabel, - }) - } - - total := float64(len(weights)) - if total == 0 { - return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") - } - - var sum float64 - for _, w := range weights { - sum += w - } - mean := sum / total - meanUpThreshold := roundToPrecision(mean*1.10, 3) - meanDownThreshold := roundToPrecision(mean*0.90, 3) - - var uniformCount float64 - for i := range details { - if hasRangeLabels { - if details[i].Range == "HIGH" || details[i].Range == "LOW" { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - continue - } - - w := details[i].Weight - if w > meanUpThreshold || w < meanDownThreshold { - details[i].Range = "Outside" - continue - } - details[i].Range = "Ideal" - uniformCount++ - } - outsideCount := total - uniformCount - - var cv float64 - if mean > 0 && total > 1 { - stddevWeights := weights - stddevCount := float64(len(stddevWeights)) - if stddevCount > 1 { - var stddevSum float64 - for _, w := range stddevWeights { - stddevSum += w - } - stddevMean := stddevSum / stddevCount - var sumSquares float64 - for _, w := range stddevWeights { - diff := w - stddevMean - sumSquares += diff * diff - } - stddev := math.Sqrt(sumSquares / (stddevCount - 1)) - cv = (stddev / mean) * 100 - } - } - - uniformity := (uniformCount / total) * 100 - - return UniformityCalculation{ - ChickQtyOfWeight: total, - MeanWeight: roundToPrecision(mean, 0), - MeanDown: roundToPrecision(mean*0.90, 0), - MeanUp: roundToPrecision(mean*1.10, 0), - UniformQty: uniformCount, - OutsideQty: outsideCount, - Uniformity: roundToPrecision(uniformity, 0), - Cv: roundToPrecision(cv, 1), - Details: details, - }, nil -} - -func roundToPrecision(value float64, precision int) float64 { - if precision < 0 { - return value - } - scale := math.Pow10(precision) - scaled := value * scale - fraction := scaled - math.Floor(scaled) - if fraction >= 0.5 { - return math.Ceil(scaled) / scale - } - return math.Floor(scaled) / scale -} diff --git a/internal/modules/production/uniformities/types/uniformity.types.go b/internal/modules/production/uniformities/types/uniformity.types.go new file mode 100644 index 00000000..877795f3 --- /dev/null +++ b/internal/modules/production/uniformities/types/uniformity.types.go @@ -0,0 +1,87 @@ +package types + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + +type UniformityChartRange struct { + Range string `json:"range"` + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + BirdCount float64 `json:"bird_count"` + IsIdealRange bool `json:"is_ideal_range"` + IdealRange string `json:"ideal_range,omitempty"` + OutsideRange string `json:"outside_range,omitempty"` +} + +type UniformityChartIdealRange struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + TotalIdealBirds float64 `json:"total_ideal_birds"` +} + +type UniformityChartStatistics struct { + MinWeight float64 `json:"min_weight"` + MaxWeight float64 `json:"max_weight"` + AverageWeight float64 `json:"average_weight"` + TotalBirdsMeasured float64 `json:"total_birds_measured"` +} + +type UniformityChartWeek struct { + HasData bool `json:"has_data"` + WeightDistribution []UniformityChartRange `json:"weight_distribution"` + IdealRange *UniformityChartIdealRange `json:"ideal_range"` + Statistics *UniformityChartStatistics `json:"statistics"` +} + +type UniformityChartBar struct { + CurrentWeek int `json:"current_week"` + AllWeeks map[int]UniformityChartWeek `json:"all_weeks"` +} + +type UniformityChartGaugeWeek struct { + Week int `json:"week"` + UniformityPercent float64 `json:"uniformity_percentage"` + IdealCount float64 `json:"ideal_count"` + OutsideIdealCount float64 `json:"outside_ideal_count"` + TotalCount float64 `json:"total_count"` + HasData bool `json:"has_data"` +} + +type UniformityChartWeekInfo struct { + TotalWeeks int `json:"total_weeks"` + WeeksWithData int `json:"weeks_with_data"` + CurrentWeekIndex int `json:"current_week_index"` + HasPrevWeek bool `json:"has_prev_week"` + HasNextWeek bool `json:"has_next_week"` +} + +type UniformityChartGauge struct { + CurrentWeek int `json:"current_week"` + AvailableWeeks []UniformityChartGaugeWeek `json:"available_weeks"` + WeekInfo UniformityChartWeekInfo `json:"week_info"` +} + +type UniformityChartData struct { + BarChart UniformityChartBar `json:"bar_chart"` + GaugeChart UniformityChartGauge `json:"gauge_chart"` +} \ No newline at end of file diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go index b2aeaf26..e4f7f8a0 100644 --- a/internal/modules/production/uniformities/validations/uniformity.validation.go +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -4,6 +4,7 @@ import ( "mime/multipart" "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" ) @@ -21,10 +22,13 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` - Week int `query:"week" validate:"omitempty,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + WithChart bool `query:"with_chart"` } type UploadExcelRequest struct { @@ -37,6 +41,8 @@ type Approve struct { Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } +const maxUniformityUploadBytes = 5 * 1024 * 1024 + func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { raw := strings.TrimSpace(c.Params(name)) if raw == "" { @@ -55,15 +61,49 @@ func ParseQuery(c *fiber.Ctx) (*Query, error) { Limit: c.QueryInt("limit", 10), ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), Week: c.QueryInt("week", 0), + StartDate: strings.TrimSpace(c.Query("start_date")), + EndDate: strings.TrimSpace(c.Query("end_date")), + WithChart: c.QueryBool("with_chart", false), } if query.Page < 1 || query.Limit < 1 { return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if _, _, err := ParseDateRange(query.StartDate, query.EndDate); err != nil { + return nil, err + } + return query, nil } +func ParseDateRange(startDate, endDate string) (*time.Time, *time.Time, error) { + var startDateValue *time.Time + var endDateValue *time.Time + + if startDate != "" { + parsed, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "start_date must be in YYYY-MM-DD format") + } + startDateValue = &parsed + } + if endDate != "" { + parsed, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be in YYYY-MM-DD format") + } + endDateValue = &parsed + } + if startDateValue != nil && endDateValue != nil { + if endDateValue.Before(*startDateValue) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date") + } + } + + return startDateValue, endDateValue, nil +} + func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { date := strings.TrimSpace(c.FormValue("date")) if date == "" { @@ -94,6 +134,9 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { if err != nil { return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err + } return &Create{ Date: date, @@ -134,6 +177,8 @@ func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { file, err := c.FormFile("document") if err != nil { file = nil + } else if err := validateUniformityFileSize(file); err != nil { + return nil, nil, err } return req, file, nil @@ -151,6 +196,9 @@ func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { if err != nil || file == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } + if err := validateUniformityFileSize(file); err != nil { + return nil, err + } return []*multipart.FileHeader{file}, nil } @@ -162,3 +210,10 @@ func ParseApprove(c *fiber.Ctx) (*Approve, error) { } return req, nil } + +func validateUniformityFileSize(file *multipart.FileHeader) error { + if file != nil && file.Size > maxUniformityUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + return nil +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index 977b4ac1..c4291619 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "mime/multipart" "strconv" "strings" @@ -15,6 +16,8 @@ import ( "github.com/gofiber/fiber/v2" ) +const maxPurchaseUploadBytes = 5 * 1024 * 1024 + type PurchaseController struct { service service.PurchaseService } @@ -184,6 +187,9 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { if len(req.TravelDocuments) == 0 { req.TravelDocuments = form.File["documents"] } + if err := validatePurchaseDocumentSizes(req.TravelDocuments); err != nil { + return err + } result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err @@ -198,6 +204,15 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { }) } +func validatePurchaseDocumentSizes(files []*multipart.FileHeader) error { + for _, file := range files { + if file != nil && file.Size > maxPurchaseUploadBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB") + } + } + return nil +} + func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { param := c.Params("id") id, err := strconv.Atoi(param)