// internal/services/match_service.go package services import ( "encoding/json" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "gorm.io/gorm" ) type MatchService struct { db *gorm.DB // Tambahkan ini matchRepo *repositories.MatchResultRepository itemRepo *repositories.ItemRepository lostItemRepo *repositories.LostItemRepository notificationRepo *repositories.NotificationRepository } func NewMatchService(db *gorm.DB) *MatchService { return &MatchService{ db: db, // Tambahkan ini matchRepo: repositories.NewMatchResultRepository(db), itemRepo: repositories.NewItemRepository(db), lostItemRepo: repositories.NewLostItemRepository(db), notificationRepo: repositories.NewNotificationRepository(db), } } // MatchedField represents a matched field between items type MatchedField struct { Field string `json:"field"` LostValue string `json:"lost_value"` FoundValue string `json:"found_value"` Score float64 `json:"score"` } // FindSimilarItems finds similar items for a lost item report func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) { lostItem, err := s.lostItemRepo.FindByID(lostItemID) if err != nil { return nil, err } // Search for items in same category items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color) if err != nil { return nil, err } var results []models.MatchResultResponse for _, item := range items { // Calculate similarity score, matchedFields := s.calculateSimilarity(lostItem, &item) // Only include if score is reasonable (>= 30%) if score >= 30.0 { // Check if match already exists exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID) if !exists { // Create match result matchedFieldsJSON, _ := json.Marshal(matchedFields) match := &models.MatchResult{ LostItemID: lostItemID, ItemID: item.ID, SimilarityScore: score, MatchedFields: string(matchedFieldsJSON), IsNotified: false, } s.matchRepo.Create(match) // Reload with relations match, _ = s.matchRepo.FindByID(match.ID) results = append(results, match.ToResponse()) } } } return results, nil } // GetMatchesForLostItem gets all matches for a lost item func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) { matches, err := s.matchRepo.FindByLostItem(lostItemID) if err != nil { return nil, err } var responses []models.MatchResultResponse for _, match := range matches { responses = append(responses, match.ToResponse()) } return responses, nil } // GetMatchesForItem gets all matches for an item func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) { matches, err := s.matchRepo.FindByItem(itemID) if err != nil { return nil, err } var responses []models.MatchResultResponse for _, match := range matches { responses = append(responses, match.ToResponse()) } return responses, nil } // AutoMatchNewItem automatically matches a new item with lost items func (s *MatchService) AutoMatchNewItem(itemID uint) error { item, err := s.itemRepo.FindByID(itemID) if err != nil { return err } // Find active lost items in same category lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID) if err != nil { return err } for _, lostItem := range lostItems { // Calculate similarity score, matchedFields := s.calculateSimilarity(&lostItem, item) // Create match if score is high enough (>= 50% for auto-match) if score >= 50.0 { // Check if match already exists exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID) if !exists { matchedFieldsJSON, _ := json.Marshal(matchedFields) match := &models.MatchResult{ LostItemID: lostItem.ID, ItemID: itemID, SimilarityScore: score, MatchedFields: string(matchedFieldsJSON), IsNotified: false, } s.matchRepo.Create(match) // Send notification to lost item owner - PERBAIKAN DI SINI models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID) s.matchRepo.MarkAsNotified(match.ID) } } } return nil } // calculateSimilarity calculates similarity between lost item and found item func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) { var matchedFields []MatchedField // 1. Hard Filter: Kategori HARUS sama. if lostItem.CategoryID != item.CategoryID { return 0.0, matchedFields } // --- A. Hitung Kecocokan Nama (50%) --- nameSim := utils.CalculateStringSimilarity(lostItem.Name, item.Name) nameScore := nameSim * 100.0 if nameScore > 40.0 { matchedFields = append(matchedFields, MatchedField{ Field: "name", LostValue: lostItem.Name, FoundValue: item.Name, Score: nameScore, }) } // --- B. Hitung Kecocokan Secret Details / Deskripsi (50%) --- // Target: Utamakan Secret Details penemu, fallback ke Description targetText := item.SecretDetails foundValueType := "Secret Details" if targetText == "" { targetText = item.Description foundValueType = "Description" } // Bandingkan Deskripsi User vs Target Penemu descSim := utils.CalculateStringSimilarity(lostItem.Description, targetText) descScore := descSim * 100.0 if descScore > 40.0 { matchedFields = append(matchedFields, MatchedField{ Field: "secret_details", LostValue: "User Description", FoundValue: foundValueType, Score: descScore, }) } // --- C. Hitung Skor Akhir --- // Rumus: (Skor Nama * 0.5) + (Skor Deskripsi * 0.5) finalScore := (nameScore * 0.5) + (descScore * 0.5) return finalScore, matchedFields }