diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index d2381b78..a29ccd91 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -207,6 +207,7 @@ const ( P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete" ) const ( + P_ChickinsGetAll = "lti.production.chickins.list" P_ChickinsCreateOne = "lti.production.chickins.create" P_ChickinsGetOne = "lti.production.chickins.detail" P_ChickinsApproval = "lti.production.chickins.approve" diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index de4b49d0..c2f258fd 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -238,17 +238,17 @@ func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error { } 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 } + withoutActivities := func(src map[string]any) map[string]any { + if src == nil { + return map[string]any{} + } + return src + } + responseData := make([]dto.DailyChecklistReportDTO, len(result)) for i, item := range result { responseData[i] = dto.DailyChecklistReportDTO{ diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go index 6869894f..abc1cea1 100644 --- a/internal/modules/daily-checklists/dto/daily-checklist.dto.go +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -72,13 +72,14 @@ type DailyChecklistPerformanceOverviewDTO struct { 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"` + DailyActivities map[string]any `json:"daily_activities"` Summary DailyChecklistReportSummaryDTO `json:"summary"` } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 45533994..8e60ef8b 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -104,7 +104,7 @@ type DailyChecklistReportItem struct { EmployeeID uint EmployeeName string PhaseName string - DailyActivities map[string]int + DailyActivities map[string]any Summary DailyChecklistReportSummary } @@ -123,12 +123,13 @@ type DailyChecklistReportCategory struct { } const ( - dailyChecklistDateLayout = "2006-01-02" - dailyChecklistCategoryEmptyKandang = "empty_kandang" - dailyChecklistStatusRejected = "REJECTED" - dailyChecklistStatusDraft = "DRAFT" - dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range" - dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" + dailyChecklistDateLayout = "2006-01-02" + dailyChecklistCategoryEmptyKandang = "empty_kandang" + dailyChecklistStatusRejected = "REJECTED" + dailyChecklistStatusDraft = "DRAFT" + dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range" + dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" + dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date" ) func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { @@ -519,21 +520,8 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) status := req.Status category := req.Category - endDate := date if req.EmptyKandang { - if strings.TrimSpace(req.EmptyKandangEndDate) == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true") - } - - endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate)) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD") - } - if endDate.Before(date) { - return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date") - } - category = dailyChecklistCategoryEmptyKandang } @@ -544,15 +532,17 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) return err } - if req.EmptyKandang { - if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil { + if category == dailyChecklistCategoryEmptyKandang { + if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil { + return err + } + if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil { + return err + } + } else { + if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date); err != nil { return err } - return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID) - } - - if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil { - return err } return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) @@ -615,6 +605,22 @@ func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kand return nil } +func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error { + var conflictCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Unscoped(). + Where("kandang_id = ? AND date = ? AND deleted_at IS NULL AND category != ?", kandangID, date, dailyChecklistCategoryEmptyKandang). + Count(&conflictCount).Error; err != nil { + return err + } + + if conflictCount > 0 { + return fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrDeletedNonEmptyKandangExists) + } + + return nil +} + func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error { existing := new(entity.DailyChecklist) err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). @@ -1659,7 +1665,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report totalChecklist := 0 categoryCounts := DailyChecklistReportCategory{} - activityOutput := make(map[string]int, len(activities)) + activityOutput := make(map[string]any, len(activities)) for day, stat := range activities { activityOutput[day] = stat.Completed @@ -1717,5 +1723,109 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report } } + // Flag empty kandang days within this report month + if len(kandangIDs) > 0 { + firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC) + lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1) + today := time.Now().UTC().Truncate(24 * time.Hour) + + type emptyKandangRec struct { + KandangID uint + Date time.Time + } + var emptyRecs []emptyKandangRec + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.DailyChecklist{}). + Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL", + kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay). + Select("kandang_id, date"). + Scan(&emptyRecs).Error; err != nil { + s.Log.Errorf("Failed to get empty kandang records for report: %+v", err) + return nil, 0, err + } + + emptyDaysByKandang := make(map[uint]map[int]struct{}) + + if len(emptyRecs) > 0 { + minEmptyDate := emptyRecs[0].Date + for _, rec := range emptyRecs[1:] { + if rec.Date.Before(minEmptyDate) { + minEmptyDate = rec.Date + } + } + + type checklistDateRec struct { + KandangID uint + Date time.Time + } + var nextDates []checklistDateRec + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.DailyChecklist{}). + Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL", + kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected). + Select("kandang_id, date"). + Order("kandang_id ASC, date ASC"). + Scan(&nextDates).Error; err != nil { + s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err) + return nil, 0, err + } + + nextDatesByKandang := make(map[uint][]time.Time) + for _, row := range nextDates { + nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date) + } + + for _, rec := range emptyRecs { + var nextDate time.Time + for _, d := range nextDatesByKandang[rec.KandangID] { + if d.After(rec.Date) { + nextDate = d + break + } + } + + // If no next checklist, cap empty period at today (not end of month) + ceiling := lastDay + if today.Before(lastDay) { + ceiling = today + } + periodEnd := ceiling + if !nextDate.IsZero() { + periodEnd = nextDate.AddDate(0, 0, -1) + } + + effectiveStart := rec.Date + if effectiveStart.Before(firstDay) { + effectiveStart = firstDay + } + effectiveEnd := periodEnd + if effectiveEnd.After(lastDay) { + effectiveEnd = lastDay + } + + if effectiveStart.After(effectiveEnd) { + continue + } + + if _, ok := emptyDaysByKandang[rec.KandangID]; !ok { + emptyDaysByKandang[rec.KandangID] = make(map[int]struct{}) + } + for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) { + emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} + } + } + } + + for i, item := range items { + daySet := emptyDaysByKandang[item.KandangID] + for day := range daySet { + key := strconv.Itoa(day) + if _, exists := items[i].DailyActivities[key]; !exists { + items[i].DailyActivities[key] = "Kandang kosong" + } + } + } + } + return 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 3738a58d..fdf19dbe 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -9,8 +9,7 @@ type Create struct { KandangId uint `json:"kandang_id" validate:"required"` Category string `json:"category" validate:"required"` Status string `json:"status" validate:"required"` - EmptyKandang bool `json:"empty_kandang"` - EmptyKandangEndDate string `json:"empty_kandang_end_date"` + EmptyKandang bool `json:"empty_kandang"` } type Update struct { diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go index c72ff2a3..b5797cb4 100644 --- a/internal/modules/finance/transactions/services/transaction.service.go +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -91,8 +91,10 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en LOWER(COALESCE(notes, '')) LIKE ? OR LOWER(COALESCE(customers.name, '')) LIKE ? OR LOWER(COALESCE(suppliers.name, '')) LIKE ? OR - LOWER(COALESCE(banks.name, '')) LIKE ?`, - like, like, like, like, like, like, like, like, + LOWER(COALESCE(banks.name, '')) LIKE ? OR + CAST(payments.nominal AS TEXT) LIKE ? OR + TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`, + like, like, like, like, like, like, like, like, like, like, ) } diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index 0d9c67e0..f4425206 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -1,6 +1,7 @@ package controller import ( + "math" "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" @@ -21,32 +22,32 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl } } -// func (u *ChickinController) GetAll(c *fiber.Ctx) error { -// query := &validation.Query{ -// Page: c.QueryInt("page", 1), -// Limit: c.QueryInt("limit", 10), -// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), -// } +func (u *ChickinController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + } -// result, totalResults, err := u.ChickinService.GetAll(c, query) -// if err != nil { -// return err -// } + result, totalResults, err := u.ChickinService.GetAll(c, query) + if err != nil { + return err + } -// return c.Status(fiber.StatusOK). -// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ -// Code: fiber.StatusOK, -// Status: "success", -// Message: "Get all chickins successfully", -// Meta: response.Meta{ -// Page: query.Page, -// Limit: query.Limit, -// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), -// TotalResults: totalResults, -// }, -// Data: dto.ToChickinListDTOs(result), -// }) -// } + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all chickins successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToChickinListDTOs(result), + }) +} // func (u *ChickinController) GetOne(c *fiber.Ctx) error { // param := c.Params("id") diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 4b49969a..4243a602 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -15,8 +15,8 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route := v1.Group("/chickins") route.Use(m.Auth(u)) - // route.Get("/", ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) + route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 8b58b8a3..ceb747ae 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -49,6 +49,7 @@ type RecordingRepository interface { DeleteEggs(tx *gorm.DB, recordingID uint) error ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error + UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) @@ -543,6 +544,12 @@ func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, tot Update("total_qty", totalQty).Error } +func (r *RecordingRepositoryImpl) UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error { + return tx.Model(&entity.RecordingEgg{}). + Where("id = ?", eggID). + Update("weight", weight).Error +} + func (r *RecordingRepositoryImpl) GetRecordingEggByID( ctx context.Context, id uint, diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 4efa6b06..c7bfe648 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -173,6 +173,37 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + // Pre-fetch transfer maps by category to avoid N+1 per-recording queries. + growingPFKIDs := make([]uint, 0, len(pfkIDs)) + layingPFKIDs := make([]uint, 0, len(pfkIDs)) + seenCat := make(map[uint]bool, len(pfkIDs)) + for i := range recordings { + pfkID := recordings[i].ProjectFlockKandangId + if pfkID == 0 || seenCat[pfkID] { + continue + } + seenCat[pfkID] = true + cat := "" + if recordings[i].ProjectFlockKandang != nil && recordings[i].ProjectFlockKandang.ProjectFlock.Id != 0 { + cat = strings.ToUpper(strings.TrimSpace(recordings[i].ProjectFlockKandang.ProjectFlock.Category)) + } + switch cat { + case string(utils.ProjectFlockCategoryGrowing): + growingPFKIDs = append(growingPFKIDs, pfkID) + case string(utils.ProjectFlockCategoryLaying): + layingPFKIDs = append(layingPFKIDs, pfkID) + } + } + sourceTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandangs(c.Context(), growingPFKIDs) + if err != nil { + return nil, 0, err + } + targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs) + if err != nil { + return nil, 0, err + } + hasTargetRecordingCache := make(map[uint]bool) + cutOverChickinAvailability := make(map[uint]bool) for i := range recordings { if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() { @@ -192,7 +223,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recordings[i].DepletionRate = &rate - populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationStateFromCaches(c.Context(), &recordings[i], sourceTransferByPFK, targetTransferByPFK, hasTargetRecordingCache) if stateErr != nil { return nil, 0, stateErr } @@ -768,6 +799,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals) if match { hasEggChanges = false + } else if recordingutil.EggQtyByWarehouseEqual(existingTotals, incomingTotals) { + // Weight-only change: update weight fields directly without touching FIFO + if err := s.updateEggWeightsOnly(tx, existingEggs, req.Eggs); err != nil { + return err + } + // hasEggChanges stays true so metrics are recomputed } else { category := "" if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { @@ -785,7 +822,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return err } - if err := ensureRecordingEggsUnused(existingEggs); err != nil { + if err := ensureRecordingEggQtyChangeSafe(existingEggs, req.Eggs); err != nil { return err } if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { @@ -1308,6 +1345,82 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil } +// evaluatePopulationMutationStateFromCaches is identical to evaluatePopulationMutationState +// but uses pre-fetched transfer maps to avoid N+1 queries in list endpoints. +func (s *recordingService) evaluatePopulationMutationStateFromCaches( + ctx context.Context, + recording *entity.Recording, + sourceTransferByPFK map[uint]*entity.LayingTransfer, + targetTransferByPFK map[uint]*entity.LayingTransfer, + hasTargetRecordingCache map[uint]bool, +) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) { + if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { + return true, false, false, false, nil, time.Time{}, nil + } + + category, err := s.resolveRecordingCategory(ctx, recording) + if err != nil { + s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } + + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer = sourceTransferByPFK[recording.ProjectFlockKandangId] + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer = targetTransferByPFK[recording.ProjectFlockKandangId] + default: + return true, false, false, false, nil, time.Time{}, nil + } + + if transfer == nil { + return true, false, false, false, nil, time.Time{}, nil + } + + transferDate := transferPhysicalMoveDate(transfer) + if transferDate.IsZero() { + return true, false, false, false, transfer, transferDate, nil + } + + transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) + _, economicCutoffDate := transferRecordingWindow(transfer) + isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate) + isLaying := !recordDate.Before(economicCutoffDate) + + populationCanChange := true + if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + populationCanChange = !(transferExecuted && !recordDate.Before(transferDate)) + + if transferExecuted && !recordDate.Before(transferDate) { + var hasTargetLayingRecording bool + if cached, ok := hasTargetRecordingCache[transfer.Id]; ok { + hasTargetLayingRecording = cached + } else { + hasTargetLayingRecording, err = s.hasAnyRecordingOnTransferTargets(ctx, transfer) + if err != nil { + s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording") + } + hasTargetRecordingCache[transfer.Id] = hasTargetLayingRecording + } + if hasTargetLayingRecording { + isTransition = false + isLaying = true + } else { + today := normalizeDateOnlyUTC(time.Now().UTC()) + if !today.Before(economicCutoffDate) { + isTransition = true + isLaying = false + } + } + } + } + + return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil +} + func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) { if transfer == nil || transfer.Id == 0 { return false, nil @@ -3672,6 +3785,44 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { return nil } +func ensureRecordingEggQtyChangeSafe(existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error { + usedByWarehouse := make(map[uint]float64) + for _, egg := range existingEggs { + usedByWarehouse[egg.ProductWarehouseId] += egg.TotalUsed + } + newQtyByWarehouse := make(map[uint]int) + for _, egg := range reqEggs { + newQtyByWarehouse[egg.ProductWarehouseId] += egg.Qty + } + for warehouseID, used := range usedByWarehouse { + if used <= 0 { + continue + } + if float64(newQtyByWarehouse[warehouseID]) < used { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Jumlah telur tidak dapat dikurangi di bawah jumlah yang sudah terjual (%.0f butir)", used)) + } + } + return nil +} + +func (s *recordingService) updateEggWeightsOnly(tx *gorm.DB, existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error { + weightByWarehouse := make(map[uint]*float64) + for i := range reqEggs { + weightByWarehouse[reqEggs[i].ProductWarehouseId] = reqEggs[i].Weight + } + for _, egg := range existingEggs { + newWeight, ok := weightByWarehouse[egg.ProductWarehouseId] + if !ok { + continue + } + if err := s.Repository.UpdateEggWeight(tx, egg.Id, newWeight); err != nil { + return err + } + } + return nil +} + func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { if tx == nil || projectFlockKandangId == 0 || from.IsZero() { return nil diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 8c21f176..1b9dfe9f 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -17,6 +17,8 @@ type TransferLayingRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) + GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) + GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) @@ -242,3 +244,121 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx cont } return &transfer, nil } + +type pfkTransferIDRow struct { + SourcePFKID uint `gorm:"column:source_pfk_id"` + TransferID uint `gorm:"column:transfer_id"` +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) { + result := make(map[uint]*entity.LayingTransfer) + if len(pfkIDs) == 0 { + return result, nil + } + + var rows []pfkTransferIDRow + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id + FROM ( + SELECT id AS transfer_id, source_project_flock_kandang_id AS source_pfk_id + FROM laying_transfers + WHERE source_project_flock_kandang_id IN ? + AND deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = id + ORDER BY a.id DESC LIMIT 1 + ) = ? + UNION ALL + SELECT lts.laying_transfer_id AS transfer_id, lts.source_project_flock_kandang_id AS source_pfk_id + FROM laying_transfer_sources lts + JOIN laying_transfers t ON t.id = lts.laying_transfer_id AND t.deleted_at IS NULL + WHERE lts.source_project_flock_kandang_id IN ? + AND lts.deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = t.id + ORDER BY a.id DESC LIMIT 1 + ) = ? + ) combined + ORDER BY source_pfk_id, transfer_id DESC + `, + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + ).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + transferIDs := make([]uint, 0, len(rows)) + pfkByTransfer := make(map[uint]uint, len(rows)) + for _, row := range rows { + transferIDs = append(transferIDs, row.TransferID) + pfkByTransfer[row.TransferID] = row.SourcePFKID + } + + var transfers []entity.LayingTransfer + if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil { + return nil, err + } + for i := range transfers { + if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 { + result[pfkID] = &transfers[i] + } + } + return result, nil +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) { + result := make(map[uint]*entity.LayingTransfer) + if len(pfkIDs) == 0 { + return result, nil + } + + var rows []pfkTransferIDRow + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id + FROM ( + SELECT ltt.laying_transfer_id AS transfer_id, ltt.target_project_flock_kandang_id AS source_pfk_id + FROM laying_transfer_targets ltt + JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL + WHERE ltt.target_project_flock_kandang_id IN ? + AND ltt.deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = t.id + ORDER BY a.id DESC LIMIT 1 + ) = ? + ) combined + ORDER BY source_pfk_id, transfer_id DESC + `, + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + ).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + transferIDs := make([]uint, 0, len(rows)) + pfkByTransfer := make(map[uint]uint, len(rows)) + for _, row := range rows { + transferIDs = append(transferIDs, row.TransferID) + pfkByTransfer[row.TransferID] = row.SourcePFKID + } + + var transfers []entity.LayingTransfer + if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil { + return nil, err + } + for i := range transfers { + if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 { + result[pfkID] = &transfers[i] + } + } + return result, nil +} diff --git a/internal/readapi/readapi.go b/internal/readapi/readapi.go index 59fe1329..1db6ba92 100644 --- a/internal/readapi/readapi.go +++ b/internal/readapi/readapi.go @@ -44,14 +44,15 @@ type parameterMeta struct { } type routeMeta struct { - Group string - Tag string - Summary string - Description string - Security securityMode - ListStyle bool - QueryParams []parameterMeta - Exclude bool + Group string + Tag string + Summary string + Description string + Security securityMode + ListStyle bool + QueryParams []parameterMeta + ExampleResponse any + Exclude bool } func RegisterRoutes(router fiber.Router) { @@ -187,6 +188,13 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any { } openAPIPath := toOpenAPIPath(route.Path) + responseContent := map[string]any{ + "schema": successSchema(meta), + } + if meta.ExampleResponse != nil { + responseContent["example"] = meta.ExampleResponse + } + operation := map[string]any{ "summary": meta.Summary, "description": meta.Description, @@ -195,9 +203,7 @@ func buildOpenAPIDocument(routes []normalizedRoute) map[string]any { "200": map[string]any{ "description": "Successful response", "content": map[string]any{ - "application/json": map[string]any{ - "schema": successSchema(meta), - }, + "application/json": responseContent, }, }, "401": map[string]any{ @@ -777,6 +783,31 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "limit", In: "query", Description: "Page size.", Example: 10}, {Name: "search", In: "query", Description: "Search keyword.", Example: "fcr"}, } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all fcrs successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "name": "FCR Broiler Standard", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + }, + }, + } + case "/api/master-data/fcrs/:id": + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get fcr successfully", + "data": map[string]any{ + "id": 1, "name": "FCR Broiler Standard", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + "fcr_standards": []map[string]any{ + {"id": 1, "weight": 0.5, "fcr_number": 1.2, "mortality": 0.5}, + {"id": 2, "weight": 1.0, "fcr_number": 1.35, "mortality": 0.3}, + {"id": 3, "weight": 1.5, "fcr_number": 1.5, "mortality": 0.25}, + }, + }, + } case "/api/master-data/flocks": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, @@ -926,6 +957,31 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id.", Required: true, Example: 1, PostmanValue: "{{project_flock_kandang_id}}"}, {Name: "record_date", In: "query", Description: "Recording date (YYYY-MM-DD).", Required: true, Example: "2026-01-01"}, } + case "/api/production/chickins": + meta.QueryParams = []parameterMeta{ + {Name: "page", In: "query", Description: "Page number.", Example: 1}, + {Name: "limit", In: "query", Description: "Page size.", Example: 10}, + {Name: "project_flock_kandang_id", In: "query", Description: "Project flock kandang id filter.", Example: 1, PostmanValue: "{{project_flock_kandang_id}}"}, + } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all chickins successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "project_flock_kandang_id": 1, + "chick_in_date": "2026-01-01T00:00:00Z", + "product_warehouse_id": 1, + "product_warehouse": map[string]any{ + "id": 1, + "product": map[string]any{"id": 1, "name": "DOC Broiler"}, + "warehouse": map[string]any{"id": 1, "name": "Gudang DOC"}, + }, + "usage_qty": 10000.0, "pending_usage_qty": 0.0, "notes": "", + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + }, + }, + } case "/api/production/transfer_layings": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, @@ -937,6 +993,53 @@ func describeRoute(route normalizedRoute) routeMeta { {Name: "flock_destination", In: "query", Description: "Comma separated destination flock ids.", Example: "3,4"}, {Name: "status", In: "query", Description: "Comma separated status values.", Example: "DRAFT,APPROVED"}, } + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get all transferLayings successfully", + "meta": map[string]any{"page": 1, "limit": 10, "total_pages": 1, "total_results": 1}, + "data": []map[string]any{ + { + "id": 1, "transfer_number": "TL-00001", + "transfer_date": "2026-01-15T00:00:00Z", + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": nil, "notes": "", + "from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"}, + "to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"}, + "created_by": 1, + "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-15T00:00:00Z", + "approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil}, + }, + }, + } + case "/api/production/transfer_layings/:id": + meta.ExampleResponse = map[string]any{ + "code": 200, "status": "success", "message": "Get transferLaying successfully", + "data": map[string]any{ + "id": 1, "transfer_number": "TL-00001", + "transfer_date": "2026-01-15T00:00:00Z", + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": nil, "notes": "", + "from_project_flock": map[string]any{"id": 1, "flock_name": "Flock A Period 1"}, + "to_project_flock": map[string]any{"id": 2, "flock_name": "Flock B Period 1"}, + "created_by": 1, "created_user": map[string]any{"id": 1, "name": "Admin"}, + "created_at": "2026-01-15T00:00:00Z", + "approval": map[string]any{"step_number": 1, "step_name": "Pengajuan", "action": nil}, + "sources": []map[string]any{ + { + "source_project_flock_kandang": map[string]any{"id": 1, "kandang_id": 1, "project_flock_id": 1, "kandang": map[string]any{"id": 1, "name": "Kandang A"}}, + "qty": 5000.0, "note": "", + }, + }, + "targets": []map[string]any{ + { + "target_project_flock_kandang": map[string]any{"id": 2, "kandang_id": 2, "project_flock_id": 2, "kandang": map[string]any{"id": 2, "name": "Kandang B"}}, + "qty": 5000.0, "note": "", + }, + }, + }, + } case "/api/production/uniformities": meta.QueryParams = []parameterMeta{ {Name: "page", In: "query", Description: "Page number.", Example: 1}, diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index b6ff693b..f0a3d328 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -148,6 +148,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool { return true } +func EggQtyByWarehouseEqual(a, b map[uint]EggTotals) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || value.Qty != other.Qty { + return false + } + } + return true +} + func DepletionRouteMapsEqual(a, b map[DepletionRoute]float64) bool { if len(a) != len(b) { return false