diff --git a/internal/services/export_test.go b/internal/services/export_test.go index 08360ea..b7c236e 100644 --- a/internal/services/export_test.go +++ b/internal/services/export_test.go @@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) { if err != nil { t.Fatalf("read summary row: %v", err) } - expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"} + expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"} for i, want := range expectedSummary { if summary[i] != want { t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i]) diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 3f8a067..1b03795 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -17,6 +17,7 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/repository" "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" ) var ErrOffline = errors.New("database is offline") @@ -357,6 +358,18 @@ func (s *Service) SyncPricelists() (int, error) { // Check if pricelist already exists locally existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) if existing != nil { + existing.Source = pl.Source + existing.Version = pl.Version + existing.Name = pl.Notification + existing.CreatedAt = pl.CreatedAt + existing.SyncedAt = time.Now() + if err := s.localDB.SaveLocalPricelist(existing); err != nil { + if syncErr == nil { + syncErr = fmt.Errorf("refresh existing pricelist %s: %w", pl.Version, err) + } + slog.Warn("failed to refresh existing local pricelist header", "version", pl.Version, "error", err) + continue + } // Backfill items for legacy/partial local caches where only pricelist metadata exists. if s.localDB.CountLocalPricelistItems(existing.ID) == 0 { itemCount, err := s.SyncPricelistItems(existing.ID) @@ -468,24 +481,29 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int } if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { - if err := tx.Create(localPL).Error; err != nil { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "server_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "source": localPL.Source, + "version": localPL.Version, + "name": localPL.Name, + "created_at": localPL.CreatedAt, + "synced_at": localPL.SyncedAt, + "is_used": localPL.IsUsed, + }), + }).Create(localPL).Error; err != nil { return fmt.Errorf("save local pricelist: %w", err) } - if len(localItems) == 0 { - return nil + if localPL.ID == 0 { + if err := tx.Where("server_id = ?", localPL.ServerID).First(localPL).Error; err != nil { + return fmt.Errorf("reload local pricelist: %w", err) + } } for i := range localItems { localItems[i].PricelistID = localPL.ID } - batchSize := 500 - for i := 0; i < len(localItems); i += batchSize { - end := i + batchSize - if end > len(localItems) { - end = len(localItems) - } - if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil { - return fmt.Errorf("save local pricelist items: %w", err) - } + if err := replaceLocalPricelistItemsTx(tx, localPL.ID, localItems); err != nil { + return fmt.Errorf("save local pricelist items: %w", err) } return nil }); err != nil { @@ -496,6 +514,27 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int return len(localItems), nil } +func replaceLocalPricelistItemsTx(tx *gorm.DB, pricelistID uint, items []localdb.LocalPricelistItem) error { + if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&localdb.LocalPricelistItem{}).Error; err != nil { + return err + } + if len(items) == 0 { + return nil + } + + batchSize := 500 + for i := 0; i < len(items); i += batchSize { + end := i + batchSize + if end > len(items) { + end = len(items) + } + if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil { + return err + } + } + return nil +} + func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) { if s.localDB == nil || pricelistRepo == nil { return diff --git a/internal/services/sync/service_pricelist_upsert_test.go b/internal/services/sync/service_pricelist_upsert_test.go new file mode 100644 index 0000000..614c013 --- /dev/null +++ b/internal/services/sync/service_pricelist_upsert_test.go @@ -0,0 +1,118 @@ +package sync + +import ( + "path/filepath" + "testing" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestSyncNewPricelistSnapshotUpsertsExistingServerID(t *testing.T) { + local := newLocalDBForUpsertTest(t) + serverDB := newServerDBForUpsertTest(t) + if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil { + t.Fatalf("migrate server pricelist tables: %v", err) + } + + serverPL := models.Pricelist{ + Source: "estimate", + Version: "B-2026-04-28-001", + Notification: "server-current", + CreatedBy: "tester", + IsActive: true, + CreatedAt: time.Now().Add(-1 * time.Hour), + } + if err := serverDB.Create(&serverPL).Error; err != nil { + t.Fatalf("create server pricelist: %v", err) + } + if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil { + t.Fatalf("create server pricelist item: %v", err) + } + + if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ + ServerID: serverPL.ID, + Source: "estimate", + Version: "old-version", + Name: "stale-local", + CreatedAt: time.Now().Add(-24 * time.Hour), + SyncedAt: time.Now().Add(-24 * time.Hour), + IsUsed: false, + }); err != nil { + t.Fatalf("seed stale local pricelist: %v", err) + } + staleLocal, err := local.GetLocalPricelistByServerID(serverPL.ID) + if err != nil { + t.Fatalf("get stale local pricelist: %v", err) + } + if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ + {PricelistID: staleLocal.ID, LotName: "OLD_LOT", Price: 99}, + }); err != nil { + t.Fatalf("seed stale local pricelist items: %v", err) + } + + svc := NewServiceWithDB(serverDB, local) + localPL := &localdb.LocalPricelist{ + ServerID: serverPL.ID, + Source: serverPL.Source, + Version: serverPL.Version, + Name: serverPL.Notification, + CreatedAt: serverPL.CreatedAt, + SyncedAt: time.Now(), + IsUsed: false, + } + + itemCount, err := svc.syncNewPricelistSnapshot(localPL) + if err != nil { + t.Fatalf("sync new pricelist snapshot: %v", err) + } + if itemCount != 1 { + t.Fatalf("expected 1 synced item, got %d", itemCount) + } + + refreshed, err := local.GetLocalPricelistByServerID(serverPL.ID) + if err != nil { + t.Fatalf("get refreshed local pricelist: %v", err) + } + if refreshed.Version != serverPL.Version { + t.Fatalf("expected local version %q, got %q", serverPL.Version, refreshed.Version) + } + if refreshed.Name != serverPL.Notification { + t.Fatalf("expected local name %q, got %q", serverPL.Notification, refreshed.Name) + } + + items, err := local.GetLocalPricelistItems(refreshed.ID) + if err != nil { + t.Fatalf("load refreshed local items: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 local item after refresh, got %d", len(items)) + } + if items[0].LotName != "CPU_A" { + t.Fatalf("expected refreshed item CPU_A, got %q", items[0].LotName) + } +} + +func newLocalDBForUpsertTest(t *testing.T) *localdb.LocalDB { + t.Helper() + localPath := filepath.Join(t.TempDir(), "local.db") + local, err := localdb.New(localPath) + if err != nil { + t.Fatalf("init local db: %v", err) + } + t.Cleanup(func() { _ = local.Close() }) + return local +} + +func newServerDBForUpsertTest(t *testing.T) *gorm.DB { + t.Helper() + serverPath := filepath.Join(t.TempDir(), "server.db") + db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{}) + if err != nil { + t.Fatalf("open server sqlite: %v", err) + } + return db +} diff --git a/internal/services/sync/service_projects_push_test.go b/internal/services/sync/service_projects_push_test.go index b21cf18..1f8a62a 100644 --- a/internal/services/sync/service_projects_push_test.go +++ b/internal/services/sync/service_projects_push_test.go @@ -434,54 +434,14 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open server sqlite: %v", err) } - if err := db.Exec(` -CREATE TABLE qt_projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - owner_username TEXT NOT NULL, - code TEXT NOT NULL, - variant TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL, - tracker_url TEXT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - is_system INTEGER NOT NULL DEFAULT 0, - created_at DATETIME, - updated_at DATETIME -);`).Error; err != nil { - t.Fatalf("create qt_projects: %v", err) - } - if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil { - t.Fatalf("create qt_projects index: %v", err) - } - if err := db.Exec(` -CREATE TABLE qt_configurations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - user_id INTEGER NULL, - owner_username TEXT NOT NULL, - project_uuid TEXT NULL, - app_version TEXT NULL, - name TEXT NOT NULL, - items TEXT NOT NULL, - total_price REAL NULL, - custom_price REAL NULL, - notes TEXT NULL, - is_template INTEGER NOT NULL DEFAULT 0, - server_count INTEGER NOT NULL DEFAULT 1, - server_model TEXT NULL, - support_code TEXT NULL, - article TEXT NULL, - pricelist_id INTEGER NULL, - warehouse_pricelist_id INTEGER NULL, - competitor_pricelist_id INTEGER NULL, - disable_price_refresh INTEGER NOT NULL DEFAULT 0, - only_in_stock INTEGER NOT NULL DEFAULT 0, - line_no INTEGER NULL, - price_updated_at DATETIME NULL, - vendor_spec TEXT NULL, - created_at DATETIME -);`).Error; err != nil { - t.Fatalf("create qt_configurations: %v", err) + if err := db.AutoMigrate( + &models.Project{}, + &models.Configuration{}, + &models.Pricelist{}, + &models.PricelistItem{}, + &models.Lot{}, + ); err != nil { + t.Fatalf("migrate server test schema: %v", err) } return db }