240 lines
6.6 KiB
Go
240 lines
6.6 KiB
Go
package localdb
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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,
|
|
},
|
|
}
|
|
|
|
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,
|
|
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.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
|
|
}
|