LocalPartnumberBook and LocalPartnumberBookItem added to AutoMigrate list in localdb.go — consistent with all other local tables. Removed incorrectly added addPartnumberBooks/addVendorSpecColumn functions from migrations.go (vendor_spec column is handled by AutoMigrate via the LocalConfiguration model field). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
868 lines
25 KiB
Go
868 lines
25 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,
|
|
},
|
|
{
|
|
id: "2026_02_13_local_project_code",
|
|
name: "Add project code to local_projects and backfill",
|
|
run: addLocalProjectCode,
|
|
},
|
|
{
|
|
id: "2026_02_13_local_project_variant",
|
|
name: "Add project variant to local_projects and backfill",
|
|
run: addLocalProjectVariant,
|
|
},
|
|
{
|
|
id: "2026_02_13_local_project_name_nullable",
|
|
name: "Allow NULL project names in local_projects",
|
|
run: allowLocalProjectNameNull,
|
|
},
|
|
{
|
|
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
|
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 {
|
|
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: ¬e,
|
|
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,
|
|
Code: "Без проекта",
|
|
Name: ptrString("Без проекта"),
|
|
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 addLocalProjectCode(tx *gorm.DB) error {
|
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Drop unique index if it already exists to allow de-duplication updates.
|
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Copy code from current project name.
|
|
if err := tx.Exec(`
|
|
UPDATE local_projects
|
|
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure any remaining blanks have a unique fallback.
|
|
if err := tx.Exec(`
|
|
UPDATE local_projects
|
|
SET code = 'P-' || uuid
|
|
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
|
if err := tx.Exec(`
|
|
WITH ranked AS (
|
|
SELECT id, code,
|
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
|
FROM local_projects
|
|
)
|
|
UPDATE local_projects
|
|
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create unique index for project codes (ignore if exists).
|
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func addLocalProjectVariant(tx *gorm.DB) error {
|
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Drop legacy code index if present.
|
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Reset code from name and clear variant.
|
|
if err := tx.Exec(`
|
|
UPDATE local_projects
|
|
SET code = TRIM(COALESCE(name, '')),
|
|
variant = ''`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// De-duplicate by assigning variant numbers: 2,3...
|
|
if err := tx.Exec(`
|
|
WITH ranked AS (
|
|
SELECT id, code,
|
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
|
FROM local_projects
|
|
)
|
|
UPDATE local_projects
|
|
SET variant = CASE
|
|
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
|
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
|
END`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
|
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Exec(`
|
|
CREATE TABLE local_projects (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
uuid TEXT NOT NULL UNIQUE,
|
|
server_id INTEGER NULL,
|
|
owner_username TEXT NOT NULL,
|
|
code TEXT NOT NULL,
|
|
variant TEXT NOT NULL DEFAULT '',
|
|
name TEXT 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,
|
|
synced_at DATETIME NULL,
|
|
sync_status TEXT DEFAULT 'local'
|
|
)`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
|
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
|
|
|
if err := tx.Exec(`
|
|
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
|
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
|
FROM local_projects_old`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
|
return 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 deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
|
var configs []LocalConfiguration
|
|
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
|
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
|
}
|
|
|
|
var removedTotal int
|
|
for i := range configs {
|
|
cfg := configs[i]
|
|
|
|
var versions []LocalConfigurationVersion
|
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
|
Order("version_no ASC, created_at ASC").
|
|
Find(&versions).Error; err != nil {
|
|
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
|
}
|
|
if len(versions) < 2 {
|
|
continue
|
|
}
|
|
|
|
deleteIDs := make([]string, 0)
|
|
deleteSet := make(map[string]struct{})
|
|
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
|
var prevKey string
|
|
hasPrev := false
|
|
|
|
for _, version := range versions {
|
|
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
|
if err != nil {
|
|
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
|
kept = append(kept, version)
|
|
hasPrev = false
|
|
continue
|
|
}
|
|
|
|
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
|
if err != nil {
|
|
kept = append(kept, version)
|
|
hasPrev = false
|
|
continue
|
|
}
|
|
|
|
if !hasPrev || key != prevKey {
|
|
kept = append(kept, version)
|
|
prevKey = key
|
|
hasPrev = true
|
|
continue
|
|
}
|
|
|
|
deleteIDs = append(deleteIDs, version.ID)
|
|
deleteSet[version.ID] = struct{}{}
|
|
}
|
|
|
|
if len(deleteIDs) == 0 {
|
|
continue
|
|
}
|
|
|
|
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
|
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
|
}
|
|
removedTotal += len(deleteIDs)
|
|
|
|
latestKeptID := kept[len(kept)-1].ID
|
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
|
if err := tx.Model(&LocalConfiguration{}).
|
|
Where("uuid = ?", cfg.UUID).
|
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
|
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
|
if err := tx.Model(&LocalConfiguration{}).
|
|
Where("uuid = ?", cfg.UUID).
|
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
|
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if removedTotal > 0 {
|
|
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|