From 51e2d1fc8361d45d27e1df17132385426aec22d2 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 6 Feb 2026 16:00:23 +0300 Subject: [PATCH] Fix local pricelist uniqueness and preserve config project on update --- internal/localdb/local_migrations_test.go | 55 +++++++++++++++++++ internal/localdb/localdb.go | 14 ++++- internal/localdb/migrations.go | 55 +++++++++++++++++++ internal/localdb/models.go | 4 +- internal/services/local_configuration.go | 18 ++++-- .../local_configuration_versioning_test.go | 42 ++++++++++++++ 6 files changed, 178 insertions(+), 10 deletions(-) diff --git a/internal/localdb/local_migrations_test.go b/internal/localdb/local_migrations_test.go index c146b33..bc16c15 100644 --- a/internal/localdb/local_migrations_test.go +++ b/internal/localdb/local_migrations_test.go @@ -3,6 +3,7 @@ package localdb import ( "path/filepath" "testing" + "time" ) func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) { @@ -70,3 +71,57 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) { t.Fatalf("expected local migrations to be recorded") } } + +func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db") + + local, err := New(dbPath) + if err != nil { + t.Fatalf("open localdb: %v", err) + } + t.Cleanup(func() { _ = local.Close() }) + + if err := local.SaveLocalPricelist(&LocalPricelist{ + ServerID: 10, + Version: "2026-02-06-001", + Name: "v1", + CreatedAt: time.Now().Add(-time.Hour), + SyncedAt: time.Now().Add(-time.Hour), + }); err != nil { + t.Fatalf("save first pricelist: %v", err) + } + + if err := local.DB().Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy + ON local_pricelists(version) + `).Error; err != nil { + t.Fatalf("create legacy unique version index: %v", err) + } + + if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix"). + Delete(&LocalSchemaMigration{}).Error; err != nil { + t.Fatalf("delete migration record: %v", err) + } + + if err := runLocalMigrations(local.DB()); err != nil { + t.Fatalf("rerun local migrations: %v", err) + } + + if err := local.SaveLocalPricelist(&LocalPricelist{ + ServerID: 11, + Version: "2026-02-06-001", + Name: "v1-duplicate-version", + CreatedAt: time.Now(), + SyncedAt: time.Now(), + }); err != nil { + t.Fatalf("save second pricelist with duplicate version: %v", err) + } + + var count int64 + if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil { + t.Fatalf("count pricelists: %v", err) + } + if count != 2 { + t.Fatalf("expected 2 pricelists, got %d", count) + } +} diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 09b4d70..267d642 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -12,10 +12,11 @@ import ( "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" - mysqlDriver "github.com/go-sql-driver/mysql" "github.com/glebarez/sqlite" + mysqlDriver "github.com/go-sql-driver/mysql" uuidpkg "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" ) @@ -557,7 +558,16 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) { // SaveLocalPricelist saves a pricelist to local SQLite func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error { - return l.db.Save(pricelist).Error + return l.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "server_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "version": pricelist.Version, + "name": pricelist.Name, + "created_at": pricelist.CreatedAt, + "synced_at": pricelist.SyncedAt, + "is_used": pricelist.IsUsed, + }), + }).Create(pricelist).Error } // GetLocalPricelists returns all local pricelists diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index 7e49e11..48ebf07 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -47,6 +48,11 @@ var localMigrations = []localMigration{ name: "Attach existing configurations to latest local pricelist and recalc usage", run: backfillConfigurationPricelists, }, + { + id: "2026_02_06_pricelist_index_fix", + name: "Use unique server_id for local pricelists and allow duplicate versions", + run: fixLocalPricelistIndexes, + }, } func runLocalMigrations(db *gorm.DB) error { @@ -237,3 +243,52 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time { } return candidate } + +func fixLocalPricelistIndexes(tx *gorm.DB) error { + type indexRow struct { + Name string `gorm:"column:name"` + Unique int `gorm:"column:unique"` + } + var indexes []indexRow + if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil { + return fmt.Errorf("list local_pricelists indexes: %w", err) + } + + for _, idx := range indexes { + if idx.Unique == 0 { + continue + } + + type indexInfoRow struct { + Name string `gorm:"column:name"` + } + var info []indexInfoRow + if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil { + return fmt.Errorf("load index info for %s: %w", idx.Name, err) + } + if len(info) != 1 || info[0].Name != "version" { + continue + } + + quoted := strings.ReplaceAll(idx.Name, `"`, `""`) + if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil { + return fmt.Errorf("drop unique version index %s: %w", idx.Name, err) + } + } + + if err := tx.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id + ON local_pricelists(server_id) + `).Error; err != nil { + return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err) + } + + if err := tx.Exec(` + CREATE INDEX IF NOT EXISTS idx_local_pricelists_version + ON local_pricelists(version) + `).Error; err != nil { + return fmt.Errorf("ensure index local_pricelists(version): %w", err) + } + + return nil +} diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 7ffc13d..703dc30 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -126,8 +126,8 @@ func (LocalConfigurationVersion) TableName() string { // LocalPricelist stores cached pricelists from server type LocalPricelist struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server - Version string `gorm:"uniqueIndex;not null" json:"version"` + ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server + Version string `gorm:"not null;index" json:"version"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` SyncedAt time.Time `json:"synced_at"` diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index a1c5c18..66e097b 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -129,9 +129,12 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re return nil, ErrConfigForbidden } - projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID) - if err != nil { - return nil, err + projectUUID := localCfg.ProjectUUID + if req.ProjectUUID != nil { + projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID) + if err != nil { + return nil, err + } } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { @@ -418,9 +421,12 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR return nil, ErrConfigNotFound } - projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID) - if err != nil { - return nil, err + projectUUID := localCfg.ProjectUUID + if req.ProjectUUID != nil { + projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID) + if err != nil { + return nil, err + } } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { diff --git a/internal/services/local_configuration_versioning_test.go b/internal/services/local_configuration_versioning_test.go index db4cd55..762fefb 100644 --- a/internal/services/local_configuration_versioning_test.go +++ b/internal/services/local_configuration_versioning_test.go @@ -185,6 +185,48 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil { } } +func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) { + service, local := newLocalConfigServiceForTest(t) + + project := &localdb.LocalProject{ + UUID: "project-keep", + OwnerUsername: "tester", + Name: "Keep Project", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SyncStatus: "synced", + } + if err := local.SaveProject(project); err != nil { + t.Fatalf("save project: %v", err) + } + + created, err := service.Create("tester", &CreateConfigRequest{ + Name: "cfg", + ProjectUUID: &project.UUID, + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID { + t.Fatalf("expected created config project_uuid=%s", project.UUID) + } + + updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{ + Name: "cfg-updated", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("update config without project_uuid: %v", err) + } + if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID { + t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID) + } +} + func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) { t.Helper()