sync: clean stale local pricelists and migrate runtime config handling
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,6 +56,25 @@ func ResolveConfigPath(explicitPath string) (string, error) {
|
||||
return filepath.Join(dir, defaultCfg), nil
|
||||
}
|
||||
|
||||
// ResolveConfigPathNearDB returns config path using priority:
|
||||
// explicit CLI path > QFS_CONFIG_PATH > directory of resolved local DB path.
|
||||
// Falls back to ResolveConfigPath when dbPath is empty.
|
||||
func ResolveConfigPathNearDB(explicitPath, dbPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dbPath) != "" {
|
||||
return filepath.Join(filepath.Dir(filepath.Clean(dbPath)), defaultCfg), nil
|
||||
}
|
||||
|
||||
return ResolveConfigPath("")
|
||||
}
|
||||
|
||||
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
|
||||
// to targetPath if targetPath does not already exist.
|
||||
// Returns source path if migration happened.
|
||||
|
||||
@@ -719,6 +719,47 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||
return l.db.Delete(&LocalPricelist{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
|
||||
// and not referenced by active local configurations.
|
||||
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
|
||||
returned := 0
|
||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []LocalPricelist
|
||||
query := tx.Model(&LocalPricelist{})
|
||||
if len(serverPricelistIDs) > 0 {
|
||||
query = query.Where("server_id NOT IN ?", serverPricelistIDs)
|
||||
}
|
||||
if err := query.Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range candidates {
|
||||
pl := candidates[i]
|
||||
var refs int64
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
|
||||
Count(&refs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refs > 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
returned++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return returned, nil
|
||||
}
|
||||
|
||||
// PendingChange methods
|
||||
|
||||
// AddPendingChange adds a change to the sync queue
|
||||
|
||||
@@ -340,6 +340,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||
}
|
||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||
for i := range serverPricelists {
|
||||
serverPricelistIDs = append(serverPricelistIDs, serverPricelists[i].ID)
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var latestEstimateLocalID uint
|
||||
@@ -388,6 +392,13 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
synced++
|
||||
}
|
||||
|
||||
removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs)
|
||||
if err != nil {
|
||||
slog.Warn("failed to cleanup stale local pricelists", "error", err)
|
||||
} else if removed > 0 {
|
||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||
}
|
||||
|
||||
// Update component prices from latest estimate pricelist only.
|
||||
if latestEstimateLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(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: "2026-01-01-001",
|
||||
Notification: "server",
|
||||
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: 9991,
|
||||
Source: "estimate",
|
||||
Version: "old-unused",
|
||||
Name: "old-unused",
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local missing pricelist: %v", err)
|
||||
}
|
||||
missingUsed := &localdb.LocalPricelist{
|
||||
ServerID: 9992,
|
||||
Source: "estimate",
|
||||
Version: "old-used",
|
||||
Name: "old-used",
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
SyncedAt: time.Now().Add(-2 * time.Hour),
|
||||
IsUsed: false,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(missingUsed); err != nil {
|
||||
t.Fatalf("seed local referenced pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
OriginalUsername: "tester",
|
||||
Name: "cfg",
|
||||
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1}},
|
||||
IsActive: true,
|
||||
PricelistID: &missingUsed.ServerID,
|
||||
SyncStatus: "synced",
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelists(); err != nil {
|
||||
t.Fatalf("sync pricelists: %v", err)
|
||||
}
|
||||
|
||||
if _, err := local.GetLocalPricelistByServerID(9991); err == nil {
|
||||
t.Fatalf("expected unused missing local pricelist to be deleted")
|
||||
}
|
||||
if _, err := local.GetLocalPricelistByServerID(9992); err != nil {
|
||||
t.Fatalf("expected local pricelist referenced by active config to stay: %v", err)
|
||||
}
|
||||
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err != nil {
|
||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user