Implement persistent Line ordering for project specs and update bible
This commit is contained in:
@@ -33,6 +33,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -80,6 +81,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
@@ -253,3 +253,63 @@ func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *te
|
||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
projectUUID := "project-line"
|
||||
cfg1 := &LocalConfiguration{
|
||||
UUID: "line-cfg-1",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 1",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
cfg2 := &LocalConfiguration{
|
||||
UUID: "line-cfg-2",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 2",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg1); err != nil {
|
||||
t.Fatalf("save cfg1: %v", err)
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg2); err != nil {
|
||||
t.Fatalf("save cfg2: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
|
||||
t.Fatalf("reset line_no: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").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)
|
||||
}
|
||||
|
||||
var rows []LocalConfiguration
|
||||
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("load configurations: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Line != 10 || rows[1].Line != 20 {
|
||||
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
|
||||
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||
Order("created_at DESC").
|
||||
Order(configurationLineOrderClause()).
|
||||
Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
@@ -514,9 +514,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
|
||||
// SaveConfiguration saves a configuration to local SQLite
|
||||
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||
if config != nil && config.IsActive && config.Line <= 0 {
|
||||
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.Line = line
|
||||
}
|
||||
return l.db.Save(config).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
|
||||
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
|
||||
}
|
||||
|
||||
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
|
||||
query := tx.Model(&LocalConfiguration{}).
|
||||
Where("is_active = ?", true)
|
||||
|
||||
trimmedExclude := strings.TrimSpace(excludeUUID)
|
||||
if trimmedExclude != "" {
|
||||
query = query.Where("uuid <> ?", trimmedExclude)
|
||||
}
|
||||
|
||||
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
|
||||
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
|
||||
} else {
|
||||
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
|
||||
}
|
||||
|
||||
var maxLine int
|
||||
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
|
||||
return 0, fmt.Errorf("read max line_no: %w", err)
|
||||
}
|
||||
if maxLine < 0 {
|
||||
maxLine = 0
|
||||
}
|
||||
|
||||
next := ((maxLine / 10) + 1) * 10
|
||||
if next < 10 {
|
||||
next = 10
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func configurationLineOrderClause() string {
|
||||
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
|
||||
}
|
||||
|
||||
// GetConfigurations returns all local configurations
|
||||
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
|
||||
@@ -108,6 +108,11 @@ var localMigrations = []localMigration{
|
||||
name: "Deduplicate configuration revisions by spec+price",
|
||||
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||
},
|
||||
{
|
||||
id: "2026_02_19_local_config_line_no",
|
||||
name: "Add line_no to local_configurations and backfill ordering",
|
||||
run: addLocalConfigurationLineNo,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -806,3 +811,56 @@ func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalConfigurationLineNo(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_configurations')
|
||||
WHERE name IN ('line_no')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN line_no INTEGER
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_configurations.line_no: %w", err)
|
||||
}
|
||||
slog.Info("added line_no to local_configurations")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM local_configurations
|
||||
WHERE line_no IS NULL OR line_no <= 0
|
||||
)
|
||||
UPDATE local_configurations
|
||||
SET line_no = (
|
||||
SELECT rn * 10
|
||||
FROM ranked
|
||||
WHERE ranked.id = local_configurations.id
|
||||
)
|
||||
WHERE id IN (SELECT id FROM ranked)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
|
||||
ON local_configurations(project_uuid, line_no)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,35 +83,36 @@ func (s *LocalStringList) Scan(value interface{}) error {
|
||||
|
||||
// LocalConfiguration stores configurations in local SQLite
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
|
||||
@@ -28,6 +28,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
@@ -61,6 +62,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
@@ -90,6 +92,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
|
||||
Reference in New Issue
Block a user