Files
QuoteForge/internal/localdb/migrations.go
2026-02-11 19:16:01 +03:00

568 lines
16 KiB
Go

package localdb
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LocalSchemaMigration struct {
ID string `gorm:"primaryKey;size:128"`
Name string `gorm:"not null;size:255"`
AppliedAt time.Time `gorm:"not null"`
}
func (LocalSchemaMigration) TableName() string {
return "local_schema_migrations"
}
type localMigration struct {
id string
name string
run func(tx *gorm.DB) error
}
var localMigrations = []localMigration{
{
id: "2026_02_04_versioning_backfill",
name: "Ensure configuration versioning data and current pointers",
run: backfillConfigurationVersions,
},
{
id: "2026_02_04_is_active_backfill",
name: "Ensure is_active defaults to true for existing configurations",
run: backfillConfigurationIsActive,
},
{
id: "2026_02_06_projects_backfill",
name: "Create default projects and attach existing configurations",
run: backfillProjectsForConfigurations,
},
{
id: "2026_02_06_pricelist_backfill",
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,
},
{
id: "2026_02_06_pricelist_source",
name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource,
},
{
id: "2026_02_09_drop_component_unused_fields",
name: "Remove current_price and synced_at from local_components (unused fields)",
run: dropComponentUnusedFields,
},
{
id: "2026_02_09_add_warehouse_competitor_pricelists",
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
run: addWarehouseCompetitorPriceLists,
},
{
id: "2026_02_11_local_pricelist_item_category",
name: "Add lot_category to local_pricelist_items and create indexes",
run: addLocalPricelistItemCategoryAndIndexes,
},
{
id: "2026_02_11_local_config_article",
name: "Add article to local_configurations",
run: addLocalConfigurationArticle,
},
{
id: "2026_02_11_local_config_server_model",
name: "Add server_model to local_configurations",
run: addLocalConfigurationServerModel,
},
{
id: "2026_02_11_local_config_support_code",
name: "Add support_code to local_configurations",
run: addLocalConfigurationSupportCode,
},
}
func runLocalMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&LocalSchemaMigration{}); err != nil {
return fmt.Errorf("migrate local schema migrations table: %w", err)
}
for _, migration := range localMigrations {
var count int64
if err := db.Model(&LocalSchemaMigration{}).Where("id = ?", migration.id).Count(&count).Error; err != nil {
return fmt.Errorf("check local migration %s: %w", migration.id, err)
}
if count > 0 {
continue
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := migration.run(tx); err != nil {
return fmt.Errorf("run migration %s: %w", migration.id, err)
}
record := &LocalSchemaMigration{
ID: migration.id,
Name: migration.name,
AppliedAt: time.Now(),
}
if err := tx.Create(record).Error; err != nil {
return fmt.Errorf("insert migration %s record: %w", migration.id, err)
}
return nil
}); err != nil {
return err
}
slog.Info("local migration applied", "id", migration.id, "name", migration.name)
}
return nil
}
func backfillConfigurationVersions(tx *gorm.DB) error {
var configs []LocalConfiguration
if err := tx.Find(&configs).Error; err != nil {
return fmt.Errorf("load local configurations for backfill: %w", err)
}
for i := range configs {
cfg := configs[i]
var versionCount int64
if err := tx.Model(&LocalConfigurationVersion{}).
Where("configuration_uuid = ?", cfg.UUID).
Count(&versionCount).Error; err != nil {
return fmt.Errorf("count versions for %s: %w", cfg.UUID, err)
}
if versionCount == 0 {
snapshot, err := BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build initial snapshot for %s: %w", cfg.UUID, err)
}
note := "Initial snapshot backfill (v1)"
version := LocalConfigurationVersion{
ID: uuid.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: "backfill",
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
}
if err := tx.Create(&version).Error; err != nil {
return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err)
}
}
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
var latest LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", latest.ID).Error; err != nil {
return fmt.Errorf("set current version for %s: %w", cfg.UUID, err)
}
}
}
return nil
}
func backfillConfigurationIsActive(tx *gorm.DB) error {
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
}
func backfillProjectsForConfigurations(tx *gorm.DB) error {
var owners []string
if err := tx.Model(&LocalConfiguration{}).
Distinct("original_username").
Pluck("original_username", &owners).Error; err != nil {
return fmt.Errorf("load owners for projects backfill: %w", err)
}
for _, owner := range owners {
project, err := ensureDefaultProjectTx(tx, owner)
if err != nil {
return err
}
if err := tx.Model(&LocalConfiguration{}).
Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
Update("project_uuid", project.UUID).Error; err != nil {
return fmt.Errorf("assign default project for owner %s: %w", owner, err)
}
}
return nil
}
func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
var project LocalProject
err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
First(&project).Error
if err == nil {
return &project, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
}
now := time.Now()
project = LocalProject{
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: "Без проекта",
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := tx.Create(&project).Error; err != nil {
return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
}
return &project, nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load latest local pricelist: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id IS NULL").
Update("pricelist_id", latest.ServerID).Error; err != nil {
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
}
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return fmt.Errorf("reset local pricelist usage flags: %w", err)
}
if err := tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error; err != nil {
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
}
return nil
}
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() {
return fallback
}
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
}
func backfillLocalPricelistSource(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE local_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = ''
`).Error; err != nil {
return fmt.Errorf("backfill local_pricelists.source: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
ON local_pricelists(source, created_at DESC)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
}
return nil
}
func dropComponentUnusedFields(tx *gorm.DB) error {
// Check if columns exist
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_components')
WHERE name IN ('current_price', 'synced_at')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check columns existence: %w", err)
}
if len(columns) == 0 {
slog.Info("unused fields already removed from local_components")
return nil
}
// SQLite: recreate table without current_price and synced_at
if err := tx.Exec(`
CREATE TABLE local_components_new (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
category TEXT,
model TEXT
)
`).Error; err != nil {
return fmt.Errorf("create new local_components table: %w", err)
}
if err := tx.Exec(`
INSERT INTO local_components_new (lot_name, lot_description, category, model)
SELECT lot_name, lot_description, category, model
FROM local_components
`).Error; err != nil {
return fmt.Errorf("copy data to new table: %w", err)
}
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
return fmt.Errorf("drop old table: %w", err)
}
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
return fmt.Errorf("rename new table: %w", err)
}
slog.Info("dropped current_price and synced_at columns from local_components")
return nil
}
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
// Check if columns exist
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 ('warehouse_pricelist_id', 'competitor_pricelist_id')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check columns existence: %w", err)
}
if len(columns) == 2 {
slog.Info("warehouse and competitor pricelist columns already exist")
return nil
}
// Add columns if they don't exist
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN warehouse_pricelist_id INTEGER
`).Error; err != nil {
// Column might already exist, ignore
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
}
}
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN competitor_pricelist_id INTEGER
`).Error; err != nil {
// Column might already exist, ignore
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
}
}
// Create indexes
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
ON local_configurations(warehouse_pricelist_id)
`).Error; err != nil {
return fmt.Errorf("create warehouse pricelist index: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
ON local_configurations(competitor_pricelist_id)
`).Error; err != nil {
return fmt.Errorf("create competitor pricelist index: %w", err)
}
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
return nil
}
func addLocalPricelistItemCategoryAndIndexes(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_pricelist_items')
WHERE name IN ('lot_category')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_pricelist_items
ADD COLUMN lot_category TEXT
`).Error; err != nil {
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
}
slog.Info("added lot_category to local_pricelist_items")
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
ON local_pricelist_items(lot_category)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
}
return nil
}
func addLocalConfigurationArticle(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 ('article')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(article) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN article TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.article: %w", err)
}
slog.Info("added article to local_configurations")
}
return nil
}
func addLocalConfigurationServerModel(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 ('server_model')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN server_model TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.server_model: %w", err)
}
slog.Info("added server_model to local_configurations")
}
return nil
}
func addLocalConfigurationSupportCode(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 ('support_code')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN support_code TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.support_code: %w", err)
}
slog.Info("added support_code to local_configurations")
}
return nil
}