package sync import ( "encoding/json" "fmt" "log/slog" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" "gorm.io/gorm" ) // PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite. // Append-only for headers; re-pulls items if a book header exists but has 0 items. func (s *Service) PullPartnumberBooks() (int, error) { slog.Info("starting partnumber book pull") mariaDB, err := s.getDB() if err != nil { return 0, fmt.Errorf("database not available: %w", err) } localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB()) type serverBook struct { ID int `gorm:"column:id"` Version string `gorm:"column:version"` CreatedAt time.Time `gorm:"column:created_at"` IsActive bool `gorm:"column:is_active"` PartnumbersJSON string `gorm:"column:partnumbers_json"` } var serverBooks []serverBook if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil { return 0, fmt.Errorf("querying server partnumber books: %w", err) } slog.Info("partnumber books found on server", "count", len(serverBooks)) pulled := 0 for _, sb := range serverBooks { var existing localdb.LocalPartnumberBook err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON) if errPartnumbers != nil { slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers) continue } if err == nil { existing.Version = sb.Version existing.CreatedAt = sb.CreatedAt existing.IsActive = sb.IsActive existing.PartnumbersJSON = partnumbers if err := localBookRepo.SaveBook(&existing); err != nil { slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err) continue } localItemCount := localBookRepo.CountBookItems(existing.ID) if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) { slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount) continue } slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version) n, err := pullBookItems(mariaDB, localBookRepo, existing.PartnumbersJSON) if err != nil { slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err) } else { slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n) pulled++ } continue } slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive) localBook := &localdb.LocalPartnumberBook{ ServerID: sb.ID, Version: sb.Version, CreatedAt: sb.CreatedAt, IsActive: sb.IsActive, PartnumbersJSON: partnumbers, } if err := localBookRepo.SaveBook(localBook); err != nil { slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err) continue } n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON) if err != nil { slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err) continue } slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n) pulled++ } slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks)) return pulled, nil } // pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite. // Returns the number of items saved. func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) { if len(partnumbers) == 0 { return 0, nil } type serverItem struct { Partnumber string `gorm:"column:partnumber"` LotsJSON string `gorm:"column:lots_json"` Description string `gorm:"column:description"` } var serverItems []serverItem err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error if err != nil { return 0, fmt.Errorf("querying items from server: %w", err) } slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers)) if len(serverItems) == 0 { slog.Warn("server returned 0 partnumber book items") return 0, nil } localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems)) for _, si := range serverItems { var lots localdb.LocalPartnumberBookLots if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil { return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err) } localItems = append(localItems, localdb.LocalPartnumberBookItem{ Partnumber: si.Partnumber, LotsJSON: lots, Description: si.Description, }) } if err := repo.SaveBookItems(localItems); err != nil { return 0, fmt.Errorf("saving items to local db: %w", err) } return len(localItems), nil } func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) { if raw == "" { return localdb.LocalStringList{}, nil } var items []string if err := json.Unmarshal([]byte(raw), &items); err != nil { return nil, err } return localdb.LocalStringList(items), nil }