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 totalScore := 0.0 maxScore := 0.0 // Category match (20 points) maxScore += 20 if lostItem.CategoryID == item.CategoryID { totalScore += 20 matchedFields = append(matchedFields, MatchedField{ Field: "category", LostValue: lostItem.Category.Name, FoundValue: item.Category.Name, Score: 20, }) } // Name similarity (30 points) maxScore += 30 nameSimilarity := utils.CalculateStringSimilarity(lostItem.Name, item.Name) nameScore := nameSimilarity * 30 totalScore += nameScore if nameScore > 10 { matchedFields = append(matchedFields, MatchedField{ Field: "name", LostValue: lostItem.Name, FoundValue: item.Name, Score: nameScore, }) } // Color match (15 points) if lostItem.Color != "" { maxScore += 15 colorSimilarity := utils.CalculateStringSimilarity(lostItem.Color, item.Name+" "+item.Description) colorScore := colorSimilarity * 15 totalScore += colorScore if colorScore > 5 { matchedFields = append(matchedFields, MatchedField{ Field: "color", LostValue: lostItem.Color, FoundValue: "matched in description", Score: colorScore, }) } } // Location match (20 points) if lostItem.Location != "" { maxScore += 20 locationSimilarity := utils.CalculateStringSimilarity(lostItem.Location, item.Location) locationScore := locationSimilarity * 20 totalScore += locationScore if locationScore > 10 { matchedFields = append(matchedFields, MatchedField{ Field: "location", LostValue: lostItem.Location, FoundValue: item.Location, Score: locationScore, }) } } // Description keywords match (15 points) maxScore += 15 descSimilarity := utils.CalculateStringSimilarity(lostItem.Description, item.Description) descScore := descSimilarity * 15 totalScore += descScore if descScore > 5 { matchedFields = append(matchedFields, MatchedField{ Field: "description", LostValue: "keywords matched", FoundValue: "keywords matched", Score: descScore, }) } // Calculate percentage percentage := (totalScore / maxScore) * 100 if percentage > 100 { percentage = 100 } return percentage, matchedFields }