package recording import ( "context" "fmt" "strings" "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type warnLogger interface { Warnf(format string, args ...any) } type productWarehouseExistsRepo interface { ExistsByID(ctx context.Context, id uint) (bool, error) } type recordingValidationRepo interface { ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { if repo == nil || len(ids) == 0 { return nil } for _, id := range ids { ok, err := repo.ExistsByID(ctx, id) if err != nil { return err } if !ok { return fmt.Errorf("product warehouse %d not found", id) } } return nil } func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error { if repo == nil || len(ids) == 0 { return nil } invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags) if err != nil { return err } if invalidID != 0 { return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label) } return nil } type idGetter[T any] func(T) uint func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint { if len(items) == 0 { return nil } ids := make([]uint, 0, len(items)) for _, item := range items { if id := getID(item); id != 0 { ids = append(ids, id) } } return ids } func EnsureProductWarehousesByFlagsForItems[T any]( ctx context.Context, repo recordingValidationRepo, items []T, getID idGetter[T], flags []string, label string, ) error { ids := CollectWarehouseIDs(items, getID) return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label) } func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { base := 0.0 if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { base = *prevRecording.TotalChickQty } else if totalChick > 0 { base = float64(totalChick) + currentDepletion } if base <= 0 { return 0 } return (currentDepletion / base) * 100 } func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { if len(items) == 0 || approvalSvc == nil { return nil } ids := make([]uint, 0, len(items)) visited := make(map[uint]struct{}, len(items)) for _, item := range items { if item.Id == 0 { continue } if _, ok := visited[item.Id]; ok { continue } visited[item.Id] = struct{}{} ids = append(ids, item.Id) } if len(ids) == 0 { return nil } latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { if logger != nil { logger.Warnf("Unable to load latest approvals for recordings: %+v", err) } return nil } if len(latestMap) == 0 { return nil } for i := range items { if items[i].Id == 0 { continue } if approval, ok := latestMap[items[i].Id]; ok { items[i].LatestApproval = approval } } return nil } func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { if item == nil || item.Id == 0 || approvalSvc == nil { return nil } latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { if logger != nil { logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) } return nil } item.LatestApproval = latest return nil } type productionStandardValues struct { HenDay *float64 HenHouse *float64 FeedIntake *float64 MaxDepletion *float64 EggMass *float64 EggWeight *float64 } func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error { if len(items) == 0 { return nil } type standardKey struct { standardID uint week int } type standardCacheEntry struct { values productionStandardValues fcr *float64 } if db == nil { return nil } standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) cache := make(map[standardKey]standardCacheEntry, len(items)) standardIDs := make(map[uint]struct{}, len(items)) for _, item := range items { if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { continue } if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} } } standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) for standardID := range standardIDs { details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) if err != nil { if warnOnly { if logger != nil { logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) } } else { return err } continue } detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) for i := range details { detail := details[i] detailMap[detail.Week] = &detail } standardDetailByStd[standardID] = detailMap growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) if err != nil { if warnOnly { if logger != nil { logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) } } else { return err } continue } growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) for i := range growths { growth := growths[i] growthMap[growth.Week] = &growth } growthDetailByStd[standardID] = growthMap } // Batch-load laying transfer targets → source PFK chick_in_dates // untuk menentukan actual chicken week (bukan hardcode LayingWeekStart offset) type transferChickIn struct { TargetPFKID uint ChickInDate time.Time } layingPFKIDs := collectLayingPFKIDs(items) sourceChickInByTarget := make(map[uint]time.Time, len(layingPFKIDs)) if len(layingPFKIDs) > 0 { var results []transferChickIn db.Raw(` SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, pc.chick_in_date FROM laying_transfer_targets ltt JOIN laying_transfer_sources lts ON lts.laying_transfer_id = ltt.laying_transfer_id JOIN project_chickins pc ON pc.project_flock_kandang_id = lts.source_project_flock_kandang_id WHERE ltt.target_project_flock_kandang_id IN ? AND ltt.deleted_at IS NULL AND lts.deleted_at IS NULL AND pc.deleted_at IS NULL `, layingPFKIDs).Scan(&results) for _, r := range results { sourceChickInByTarget[r.TargetPFKID] = r.ChickInDate } } for _, item := range items { if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { continue } standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId if standardID == 0 { continue } week := computeTransferAwareWeek(item, sourceChickInByTarget) item.StandardWeek = &week cacheKey := standardKey{standardID: standardID, week: week} if cached, ok := cache[cacheKey]; ok { applyProductionStandardValues(item, cached.values, cached.fcr) continue } values := productionStandardValues{} var fcr *float64 if detailMap, ok := standardDetailByStd[standardID]; ok { if detail, ok := detailMap[week]; ok { values.HenDay = detail.TargetHenDayProduction values.HenHouse = detail.TargetHenHouseProduction values.EggMass = detail.TargetEggMass values.EggWeight = detail.TargetEggWeight fcr = detail.StandardFCR } } if growthMap, ok := growthDetailByStd[standardID]; ok { if growth, ok := growthMap[week]; ok { values.FeedIntake = growth.FeedIntake values.MaxDepletion = growth.MaxDepletion } } cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} applyProductionStandardValues(item, values, fcr) } return nil } func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) { item.StandardHenDay = values.HenDay item.StandardHenHouse = values.HenHouse item.StandardFeedIntake = values.FeedIntake item.StandardMaxDepletion = values.MaxDepletion item.StandardEggMass = values.EggMass item.StandardEggWeight = values.EggWeight item.StandardFcr = fcr } // collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying func collectLayingPFKIDs(items []*entity.Recording) []uint { seen := make(map[uint]struct{}) var ids []uint for _, item := range items { if item == nil || item.ProjectFlockKandang == nil { continue } if strings.EqualFold(item.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) { id := item.ProjectFlockKandang.Id if _, ok := seen[id]; !ok { seen[id] = struct{}{} ids = append(ids, id) } } } return ids } // computeTransferAwareWeek menghitung production standard week untuk recording. // Laying dengan transfer: actual chicken age dari source PFK chick_in_date. // Laying cut-over (tanpa transfer): langsung dari recording.day (tanpa offset LayingWeekStart). // Non-laying: ((day-1)/7) + 1. func computeTransferAwareWeek(item *entity.Recording, sourceChickInByTarget map[uint]time.Time) int { day := intValue(item.Day) if item == nil || item.ProjectFlockKandang == nil { if day > 0 { return ((day - 1) / 7) + 1 } return 0 } isLaying := strings.EqualFold(item.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) if !isLaying { if day > 0 { return ((day - 1) / 7) + 1 } return 0 } // Laying recording — cek apakah PFK ini adalah target dari laying transfer if sourceChickIn, ok := sourceChickInByTarget[item.ProjectFlockKandang.Id]; ok && !sourceChickIn.IsZero() { // Ada laying transfer: hitung umur aktual dari source PFK chick_in_date rDate := time.Date(item.RecordDatetime.Year(), item.RecordDatetime.Month(), item.RecordDatetime.Day(), 0, 0, 0, 0, item.RecordDatetime.Location()) sDate := time.Date(sourceChickIn.Year(), sourceChickIn.Month(), sourceChickIn.Day(), 0, 0, 0, 0, sourceChickIn.Location()) actualDay := int(rDate.Sub(sDate).Hours() / 24) if actualDay > 0 { return ((actualDay - 1) / 7) + 1 } return 0 } // Cut-over laying (tanpa transfer): chick_in_date di PFK sudah umur asli DOC if day > 0 { return ((day - 1) / 7) + 1 } return 0 } func RecordingWeekValue(e entity.Recording) int { day := intValue(e.Day) if day <= 0 { return 0 } weekBase := 1 if IsLayingRecording(e) { weekBase = config.LayingWeekStart() } return ((day - 1) / 7) + weekBase } func IsLayingRecording(e entity.Recording) bool { if e.ProjectFlockKandang == nil { return false } return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) } func intValue(value *int) int { if value == nil { return 0 } return *value }