package localdb import ( "fmt" "log/slog" "strings" "time" "gorm.io/gorm" ) // ComponentFilter for searching with filters type ComponentFilter struct { Category string Search string HasPrice bool } // ComponentSyncResult contains statistics from component sync type ComponentSyncResult struct { TotalSynced int NewCount int UpdateCount int Duration time.Duration } // SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { startTime := time.Now() // Query to join lot with qt_lot_metadata (metadata only, no pricing) // Use LEFT JOIN to include lots without metadata type componentRow struct { LotName string LotDescription string Category *string Model *string } var rows []componentRow err := mariaDB.Raw(` SELECT l.lot_name, l.lot_description, COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, m.model FROM lot l LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name LEFT JOIN qt_categories c ON m.category_id = c.id WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL ORDER BY l.lot_name `).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("querying components from MariaDB: %w", err) } if len(rows) == 0 { slog.Warn("no components found in MariaDB") return &ComponentSyncResult{ Duration: time.Since(startTime), }, nil } // Get existing local components for comparison existingMap := make(map[string]bool) var existing []LocalComponent if err := l.db.Find(&existing).Error; err != nil { return nil, fmt.Errorf("reading existing local components: %w", err) } for _, c := range existing { existingMap[c.LotName] = true } // Prepare components for batch insert/update syncTime := time.Now() components := make([]LocalComponent, 0, len(rows)) newCount := 0 for _, row := range rows { category := "" if row.Category != nil { category = *row.Category } else { // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") parts := strings.SplitN(row.LotName, "_", 2) if len(parts) >= 1 { category = parts[0] } } model := "" if row.Model != nil { model = *row.Model } comp := LocalComponent{ LotName: row.LotName, LotDescription: row.LotDescription, Category: category, Model: model, } components = append(components, comp) if !existingMap[row.LotName] { newCount++ } } // Use transaction for bulk upsert err = l.db.Transaction(func(tx *gorm.DB) error { // Delete all existing and insert new (simpler than upsert for SQLite) if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil { return fmt.Errorf("clearing local components: %w", err) } // Batch insert batchSize := 500 for i := 0; i < len(components); i += batchSize { end := i + batchSize if end > len(components) { end = len(components) } if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil { return fmt.Errorf("inserting components batch: %w", err) } } return nil }) if err != nil { return nil, err } // Update last sync time if err := l.SetComponentSyncTime(syncTime); err != nil { slog.Warn("failed to update component sync time", "error", err) } result := &ComponentSyncResult{ TotalSynced: len(components), NewCount: newCount, UpdateCount: len(components) - newCount, Duration: time.Since(startTime), } slog.Info("components synced", "total", result.TotalSynced, "new", result.NewCount, "updated", result.UpdateCount, "duration", result.Duration) return result, nil } // SearchLocalComponents searches components in local cache by query string // Searches in lot_name, lot_description, category, and model fields func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) { if limit <= 0 { limit = 50 } var components []LocalComponent if query == "" { // Return all components with limit err := l.db.Order("lot_name").Limit(limit).Find(&components).Error return components, err } // Search with LIKE on multiple fields searchPattern := "%" + strings.ToLower(query) + "%" err := l.db.Where( "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", searchPattern, searchPattern, searchPattern, searchPattern, ).Order("lot_name").Limit(limit).Find(&components).Error return components, err } // SearchLocalComponentsByCategory searches components by category and optional query func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) { if limit <= 0 { limit = 50 } var components []LocalComponent db := l.db.Where("LOWER(category) = ?", strings.ToLower(category)) if query != "" { searchPattern := "%" + strings.ToLower(query) + "%" db = db.Where( "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?", searchPattern, searchPattern, searchPattern, ) } err := db.Order("lot_name").Limit(limit).Find(&components).Error return components, err } // ListComponents returns components with filtering and pagination func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { db := l.db // Apply category filter if filter.Category != "" { db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category)) } // Apply search filter if filter.Search != "" { searchPattern := "%" + strings.ToLower(filter.Search) + "%" db = db.Where( "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", searchPattern, searchPattern, searchPattern, searchPattern, ) } // Get total count var total int64 if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { return nil, 0, err } // Apply pagination and get results var components []LocalComponent if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil { return nil, 0, err } return components, total, nil } // GetLocalComponent returns a single component by lot_name func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { var component LocalComponent err := l.db.Where("lot_name = ?", lotName).First(&component).Error if err != nil { return nil, err } return &component, nil } // GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache. // Missing lots are not included in the map; caller is responsible for strict validation. func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) { result := make(map[string]string, len(lotNames)) if len(lotNames) == 0 { return result, nil } type row struct { LotName string `gorm:"column:lot_name"` Category string `gorm:"column:category"` } var rows []row if err := l.db.Model(&LocalComponent{}). Select("lot_name, category"). Where("lot_name IN ?", lotNames). Find(&rows).Error; err != nil { return nil, err } for _, r := range rows { result[r.LotName] = r.Category } return result, nil } // GetLocalComponentCategories returns distinct categories from local components func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { var categories []string err := l.db.Model(&LocalComponent{}). Distinct("category"). Where("category != ''"). Order("category"). Pluck("category", &categories).Error return categories, err } // CountLocalComponents returns the total number of local components func (l *LocalDB) CountLocalComponents() int64 { var count int64 l.db.Model(&LocalComponent{}).Count(&count) return count } // CountLocalComponentsByCategory returns component count by category func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 { var count int64 l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count) return count } // GetComponentSyncTime returns the last component sync timestamp func (l *LocalDB) GetComponentSyncTime() *time.Time { var setting struct { Value string } if err := l.db.Table("app_settings"). Where("key = ?", "last_component_sync"). First(&setting).Error; err != nil { return nil } t, err := time.Parse(time.RFC3339, setting.Value) if err != nil { return nil } return &t } // SetComponentSyncTime sets the last component sync timestamp func (l *LocalDB) SetComponentSyncTime(t time.Time) error { return l.db.Exec(` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error } // NeedComponentSync checks if component sync is needed (older than specified hours) func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool { syncTime := l.GetComponentSyncTime() if syncTime == nil { return true } return time.Since(*syncTime).Hours() > float64(maxAgeHours) }