Files
QuoteForge/internal/localdb/localdb.go
Mikhail Chusavitin e2da8b4253 Fix competitor price display and pricelist item deduplication
- Render competitor prices in Pricing tab (all three row branches)
- Add footer total accumulation for competitor column
- Deduplicate local_pricelist_items via migration + unique index
- Use ON CONFLICT DO NOTHING in SaveLocalPricelistItems to prevent duplicates on concurrent sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:33:04 +03:00

1576 lines
49 KiB
Go

package localdb
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"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"
)
// ConnectionSettings stores MariaDB connection credentials
type ConnectionSettings struct {
ID uint `gorm:"primaryKey"`
Host string `gorm:"not null"`
Port int `gorm:"not null;default:3306"`
Database string `gorm:"not null"`
User string `gorm:"not null"`
PasswordEncrypted string `gorm:"not null"` // AES encrypted
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (ConnectionSettings) TableName() string {
return "connection_settings"
}
// LocalDB manages the local SQLite database for settings
type LocalDB struct {
db *gorm.DB
path string
}
var localReadOnlyCacheTables = []string{
"local_pricelist_items",
"local_pricelists",
"local_components",
"local_partnumber_book_items",
"local_partnumber_books",
}
// ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings.
func ResetData(dbPath string) error {
if strings.TrimSpace(dbPath) == "" {
return nil
}
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat local db: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("opening sqlite database: %w", err)
}
// Order does not matter because we use DELETEs without FK constraints in SQLite.
tables := []string{
"local_projects",
"local_configurations",
"local_configuration_versions",
"local_pricelists",
"local_pricelist_items",
"local_components",
"local_sync_guard_state",
"pending_changes",
"app_settings",
}
for _, table := range tables {
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
return fmt.Errorf("clear %s: %w", table, err)
}
}
slog.Info("local database data reset", "path", dbPath)
return nil
}
// New creates a new LocalDB instance
func New(dbPath string) (*LocalDB, error) {
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
return nil, fmt.Errorf("backup local data: %w", err)
}
} else {
return nil, fmt.Errorf("resolve config path: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("opening sqlite database: %w", err)
}
if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err)
}
if err := prepareLocalPartnumberBookCatalog(db); err != nil {
return nil, fmt.Errorf("prepare local partnumber book catalog: %w", err)
}
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
return nil, fmt.Errorf("cleanup stale read-only cache temp tables: %w", err)
}
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
if db.Migrator().HasTable(&LocalProject{}) {
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
}
}
var ids []uint
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
}
for _, id := range ids {
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
}
}
}
// Auto-migrate all local tables
if err := autoMigrateLocalSchema(db); err != nil {
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
return nil, fmt.Errorf("migrating sqlite database: %w (recovery failed: %v)", err, recoveryErr)
} else if !recovered {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
if err := autoMigrateLocalSchema(db); err != nil {
return nil, fmt.Errorf("migrating sqlite database after cache recovery: %w", err)
}
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
return nil, fmt.Errorf("ensure local partnumber book item table: %w", err)
}
if err := runLocalMigrations(db); err != nil {
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w (recovery failed: %v)", err, recoveryErr)
} else if !recovered {
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
}
if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations after cache recovery: %w", err)
}
}
slog.Info("local SQLite database initialized", "path", dbPath)
return &LocalDB{
db: db,
path: dbPath,
}, nil
}
func ensureLocalProjectsTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalProject{}) {
return nil
}
if err := db.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
}
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
return nil
}
func autoMigrateLocalSchema(db *gorm.DB) error {
return db.AutoMigrate(
&ConnectionSettings{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
)
}
func sanitizeLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Old local databases may contain partially migrated catalog rows with NULL/empty
// partnumber values. SQLite table rebuild during AutoMigrate fails on such rows once
// the schema enforces NOT NULL, so remove them before AutoMigrate touches the table.
if err := db.Exec(`
DELETE FROM local_partnumber_book_items
WHERE partnumber IS NULL OR TRIM(partnumber) = ''
`).Error; err != nil {
return err
}
return nil
}
func prepareLocalPartnumberBookCatalog(db *gorm.DB) error {
if err := cleanupStaleLocalPartnumberBookCatalogTempTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("cleanup stale temp table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("sanitize catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("migrate legacy catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("ensure canonical catalog table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := validateLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("validate canonical catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
}
return nil
}
func cleanupStaleReadOnlyCacheTempTables(db *gorm.DB) error {
for _, tableName := range localReadOnlyCacheTables {
tempName := tableName + "__temp"
if !db.Migrator().HasTable(tempName) {
continue
}
if db.Migrator().HasTable(tableName) {
if err := db.Exec(`DROP TABLE ` + tempName).Error; err != nil {
return err
}
continue
}
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return err
}
}
return nil
}
func cleanupStaleLocalPartnumberBookCatalogTempTable(db *gorm.DB) error {
if !db.Migrator().HasTable("local_partnumber_book_items__temp") {
return nil
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return db.Exec(`DROP TABLE local_partnumber_book_items__temp`).Error
}
return quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp"))
}
func migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Legacy databases may still have the pre-catalog shape (`book_id`/`lot_name`) or the
// intermediate canonical shape with obsolete columns like `is_primary_pn`. Let the
// explicit rebuild logic normalize this table before GORM AutoMigrate attempts a
// table-copy migration on its own.
return migrateLocalPartnumberBookCatalog(db)
}
func ensureLocalPartnumberBookItemTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
return err
}
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error
}
func validateLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
type rawCatalogRow struct {
Partnumber string `gorm:"column:partnumber"`
LotsJSON string `gorm:"column:lots_json"`
Description string `gorm:"column:description"`
}
var rows []rawCatalogRow
if err := db.Raw(`
SELECT partnumber, lots_json, COALESCE(description, '') AS description
FROM local_partnumber_book_items
ORDER BY id ASC
`).Scan(&rows).Error; err != nil {
return fmt.Errorf("load canonical catalog rows: %w", err)
}
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
partnumber := strings.TrimSpace(row.Partnumber)
if partnumber == "" {
return errors.New("catalog contains empty partnumber")
}
if _, exists := seen[partnumber]; exists {
return fmt.Errorf("catalog contains duplicate partnumber %q", partnumber)
}
seen[partnumber] = struct{}{}
if strings.TrimSpace(row.LotsJSON) == "" {
return fmt.Errorf("catalog row %q has empty lots_json", partnumber)
}
var lots LocalPartnumberBookLots
if err := json.Unmarshal([]byte(row.LotsJSON), &lots); err != nil {
return fmt.Errorf("catalog row %q has invalid lots_json: %w", partnumber, err)
}
}
return nil
}
func recoverLocalPartnumberBookCatalog(db *gorm.DB, cause error) error {
slog.Warn("recovering broken local partnumber book catalog", "error", cause.Error())
if err := ensureLocalPartnumberBooksCatalogColumn(db); err != nil {
return fmt.Errorf("ensure local_partnumber_books.partnumbers_json during recovery: %w", err)
}
if db.Migrator().HasTable("local_partnumber_book_items__temp") {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items__temp: %w", err)
}
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items", localPartnumberBookCatalogQuarantineTableName("broken")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items: %w", err)
}
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
return fmt.Errorf("recreate local_partnumber_book_items after recovery: %w", err)
}
slog.Warn("local partnumber book catalog reset to empty cache; next sync will rebuild it")
return nil
}
func recoverFromReadOnlyCacheInitFailure(db *gorm.DB, cause error) (bool, error) {
lowerCause := strings.ToLower(cause.Error())
recoveredAny := false
for _, tableName := range affectedReadOnlyCacheTables(lowerCause) {
if err := resetReadOnlyCacheTable(db, tableName); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if strings.Contains(lowerCause, "local_partnumber_book_items") || strings.Contains(lowerCause, "local_partnumber_books") {
if err := recoverLocalPartnumberBookCatalog(db, cause); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if recoveredAny {
slog.Warn("recovered read-only local cache tables after startup failure", "error", cause.Error())
}
return recoveredAny, nil
}
func affectedReadOnlyCacheTables(lowerCause string) []string {
affected := make([]string, 0, len(localReadOnlyCacheTables))
for _, tableName := range localReadOnlyCacheTables {
if tableName == "local_partnumber_book_items" || tableName == "local_partnumber_books" {
continue
}
if strings.Contains(lowerCause, tableName) {
affected = append(affected, tableName)
}
}
return affected
}
func resetReadOnlyCacheTable(db *gorm.DB, tableName string) error {
slog.Warn("resetting read-only local cache table", "table", tableName)
tempName := tableName + "__temp"
if db.Migrator().HasTable(tempName) {
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return fmt.Errorf("quarantine temp table %s: %w", tempName, err)
}
}
if db.Migrator().HasTable(tableName) {
if err := quarantineSQLiteTable(db, tableName, localReadOnlyCacheQuarantineTableName(tableName, "broken")); err != nil {
return fmt.Errorf("quarantine table %s: %w", tableName, err)
}
}
return nil
}
func ensureLocalPartnumberBooksCatalogColumn(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBook{}) {
return nil
}
if db.Migrator().HasColumn(&LocalPartnumberBook{}, "partnumbers_json") {
return nil
}
return db.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error
}
func quarantineSQLiteTable(db *gorm.DB, tableName string, quarantineName string) error {
if !db.Migrator().HasTable(tableName) {
return nil
}
if tableName == quarantineName {
return nil
}
if db.Migrator().HasTable(quarantineName) {
if err := db.Exec(`DROP TABLE ` + quarantineName).Error; err != nil {
return err
}
}
return db.Exec(`ALTER TABLE ` + tableName + ` RENAME TO ` + quarantineName).Error
}
func localPartnumberBookCatalogQuarantineTableName(kind string) string {
return "local_partnumber_book_items_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string {
return tableName + "_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
var count int64
l.db.Model(&ConnectionSettings{}).Count(&count)
return count > 0
}
// GetSettings retrieves the connection settings with decrypted password
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
var settings ConnectionSettings
if err := l.db.First(&settings).Error; err != nil {
return nil, fmt.Errorf("getting settings: %w", err)
}
// Decrypt password
password, migrated, err := DecryptWithMetadata(settings.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypting password: %w", err)
}
if migrated {
encrypted, encryptErr := Encrypt(password)
if encryptErr != nil {
return nil, fmt.Errorf("re-encrypting legacy password: %w", encryptErr)
}
if err := l.db.Model(&ConnectionSettings{}).
Where("id = ?", settings.ID).
Update("password_encrypted", encrypted).Error; err != nil {
return nil, fmt.Errorf("upgrading legacy password encryption: %w", err)
}
}
settings.PasswordEncrypted = password // Return decrypted password in this field
return &settings, nil
}
// SaveSettings saves connection settings with encrypted password
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
// Encrypt password
encrypted, err := Encrypt(password)
if err != nil {
return fmt.Errorf("encrypting password: %w", err)
}
settings := ConnectionSettings{
ID: 1, // Always use ID=1 for single settings row
Host: host,
Port: port,
Database: database,
User: user,
PasswordEncrypted: encrypted,
}
// Upsert: create or update
result := l.db.Save(&settings)
if result.Error != nil {
return fmt.Errorf("saving settings: %w", result.Error)
}
slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
return nil
}
// DeleteSettings removes all connection settings
func (l *LocalDB) DeleteSettings() error {
return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
}
// GetDSN returns the MariaDB DSN string
func (l *LocalDB) GetDSN() (string, error) {
settings, err := l.GetSettings()
if err != nil {
return "", err
}
cfg := mysqlDriver.NewConfig()
cfg.User = settings.User
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
cfg.DBName = settings.Database
cfg.ParseTime = true
cfg.Loc = time.Local
// Add aggressive timeouts for offline-first architecture.
cfg.Timeout = 3 * time.Second
cfg.ReadTimeout = 3 * time.Second
cfg.WriteTimeout = 3 * time.Second
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN(), nil
}
// DB returns the underlying gorm.DB for advanced operations
func (l *LocalDB) DB() *gorm.DB {
return l.db
}
// Close closes the database connection
func (l *LocalDB) Close() error {
sqlDB, err := l.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// GetDBUser returns the database username from settings
func (l *LocalDB) GetDBUser() string {
settings, err := l.GetSettings()
if err != nil {
return ""
}
return settings.User
}
// Configuration methods
// Project methods
func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
if !includeArchived {
query = query.Where("is_active = ?", true)
}
err := query.Order("created_at DESC").Find(&projects).Error
return projects, err
}
func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{})
if !includeArchived {
query = query.Where("is_active = ?", true)
}
err := query.Order("created_at DESC").Find(&projects).Error
return projects, err
}
func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
var configs []LocalConfiguration
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order(configurationLineOrderClause()).
Find(&configs).Error
return configs, err
}
func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) {
project := &LocalProject{}
err := l.db.
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
First(project).Error
if err == nil {
return project, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
now := time.Now()
project = &LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := l.SaveProject(project); err != nil {
return nil, err
}
return project, nil
}
// ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project.
// Configurations are reassigned to canonical UUID, duplicate projects are deleted.
func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
var removed int64
err := l.db.Transaction(func(tx *gorm.DB) error {
var canonical LocalProject
err := tx.
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
First(&canonical).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
now := time.Now()
canonical = LocalProject{
UUID: uuidpkg.NewString(),
OwnerUsername: "",
Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true,
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
}
if err := tx.Create(&canonical).Error; err != nil {
return err
}
} else if err != nil {
return err
}
if err := tx.Model(&LocalProject{}).
Where("uuid = ?", canonical.UUID).
Updates(map[string]any{
"name": "Без проекта",
"is_system": true,
"is_active": true,
}).Error; err != nil {
return err
}
var duplicates []LocalProject
if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID).
Find(&duplicates).Error; err != nil {
return err
}
for i := range duplicates {
p := duplicates[i]
if err := tx.Model(&LocalConfiguration{}).
Where("project_uuid = ?", p.UUID).
Update("project_uuid", canonical.UUID).Error; err != nil {
return err
}
// Remove stale pending project events for deleted UUIDs.
if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID).
Delete(&PendingChange{}).Error; err != nil {
return err
}
res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{})
if res.Error != nil {
return res.Error
}
removed += res.RowsAffected
}
// Backfill orphaned local configurations to canonical project.
if err := tx.Model(&LocalConfiguration{}).
Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''").
Update("project_uuid", canonical.UUID).Error; err != nil {
return err
}
return nil
})
return removed, err
}
// PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations:
// 1) projects with empty names;
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) {
tx := l.db.Exec(`
DELETE FROM local_projects
WHERE (
TRIM(COALESCE(name, '')) = ''
OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
)
AND uuid NOT IN (
SELECT DISTINCT project_uuid
FROM local_configurations
WHERE project_uuid IS NOT NULL AND project_uuid <> ''
)`)
return tx.RowsAffected, tx.Error
}
func ptrString(value string) *string {
return &value
}
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
// If missing, it assigns system project "Без проекта" for configuration owner.
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
configs, err := l.GetConfigurations()
if err != nil {
return err
}
for i := range configs {
cfg := configs[i]
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
continue
}
owner := strings.TrimSpace(cfg.OriginalUsername)
if owner == "" {
owner = strings.TrimSpace(defaultOwner)
}
if owner == "" {
continue
}
project, err := l.EnsureDefaultProject(owner)
if err != nil {
return err
}
cfg.ProjectUUID = &project.UUID
if saveErr := l.SaveConfiguration(&cfg); saveErr != nil {
return saveErr
}
}
return nil
}
// 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
err := l.db.Order("created_at DESC").Find(&configs).Error
return configs, err
}
// GetConfigurationByUUID returns a configuration by UUID
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
var config LocalConfiguration
err := l.db.Where("uuid = ?", uuid).First(&config).Error
return &config, err
}
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
query := l.db.Model(&LocalConfiguration{})
switch status {
case "active":
query = query.Where("local_configurations.is_active = ?", true)
case "archived":
query = query.Where("local_configurations.is_active = ?", false)
case "all", "":
// no-op
default:
query = query.Where("local_configurations.is_active = ?", true)
}
search = strings.TrimSpace(search)
if search != "" {
needle := "%" + strings.ToLower(search) + "%"
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
args := []interface{}{needle}
if hasProjectsTable {
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
args = append(args, needle)
}
if hasServerModel {
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
args = append(args, needle)
}
query = query.Where(strings.Join(conditions, " OR "), args...)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var configs []LocalConfiguration
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
}
// DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.DeactivateConfiguration(uuid)
}
// DeactivateConfiguration marks configuration as inactive and appends one snapshot version.
func (l *LocalDB) DeactivateConfiguration(uuid string) error {
return l.db.Transaction(func(tx *gorm.DB) error {
var cfg LocalConfiguration
if err := tx.Where("uuid = ?", uuid).First(&cfg).Error; err != nil {
return err
}
if !cfg.IsActive {
return nil
}
cfg.IsActive = false
cfg.UpdatedAt = time.Now()
cfg.SyncStatus = "pending"
if err := tx.Save(&cfg).Error; err != nil {
return fmt.Errorf("save inactive configuration: %w", err)
}
var maxVersion int
if err := tx.Model(&LocalConfigurationVersion{}).
Where("configuration_uuid = ?", cfg.UUID).
Select("COALESCE(MAX(version_no), 0)").
Scan(&maxVersion).Error; err != nil {
return fmt.Errorf("read max version for deactivate: %w", err)
}
snapshot, err := BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build deactivate snapshot: %w", err)
}
note := "deactivate via local delete"
version := &LocalConfigurationVersion{
ID: uuidpkg.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: maxVersion + 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(),
}
if err := tx.Create(version).Error; err != nil {
return fmt.Errorf("insert deactivate version: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version after deactivate: %w", err)
}
return nil
})
}
// CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 {
var count int64
l.db.Model(&LocalConfiguration{}).Count(&count)
return count
}
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
l.db.Model(&LocalProject{}).Count(&count)
return count
}
// Pricelist methods
// GetLastSyncTime returns the last sync timestamp
func (l *LocalDB) GetLastSyncTime() *time.Time {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", "last_pricelist_sync").
First(&setting).Error; err != nil {
return nil
}
t, err := time.Parse(time.RFC3339, setting.Value)
if err != nil {
return nil
}
return &t
}
// SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64
l.db.Model(&LocalPricelist{}).Count(&count)
return count
}
// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.
Where("source = ?", "estimate").
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.
Where("source = ?", source).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByServerID returns a local pricelist by its server ID
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("version = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.First(&pricelist, id).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": pricelist.Source,
"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
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
var pricelists []LocalPricelist
if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
return nil, err
}
return pricelists, nil
}
// CountLocalPricelistItems returns the number of items for a pricelist
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
var count int64
l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
return count
}
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64
if err := l.db.Model(&LocalPricelistItem{}).
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// SaveLocalPricelistItems saves pricelist items to local SQLite.
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
}
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
})
}
// GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
var items []LocalPricelistItem
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item LocalPricelistItem
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
First(&item).Error; err != nil {
return 0, err
}
return item.Price, nil
}
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if serverPricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return nil, err
}
type row struct {
LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.LotCategory
}
return result, nil
}
// MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
Update("is_used", isUsed).Error
}
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return 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
})
}
// DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete items first
if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
// Delete pricelist
return l.db.Delete(&LocalPricelist{}, id).Error
}
// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
// and not referenced by active local configurations.
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
returned := 0
err := l.db.Transaction(func(tx *gorm.DB) error {
var candidates []LocalPricelist
query := tx.Model(&LocalPricelist{})
if len(serverPricelistIDs) > 0 {
query = query.Where("server_id NOT IN ?", serverPricelistIDs)
}
if err := query.Find(&candidates).Error; err != nil {
return err
}
for i := range candidates {
pl := candidates[i]
var refs int64
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
Count(&refs).Error; err != nil {
return err
}
if refs > 0 {
continue
}
if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
return err
}
returned++
}
return nil
})
if err != nil {
return 0, err
}
return returned, nil
}
// PendingChange methods
// AddPendingChange adds a change to the sync queue
func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error {
change := PendingChange{
EntityType: entityType,
EntityUUID: entityUUID,
Operation: operation,
Payload: payload,
CreatedAt: time.Now(),
Attempts: 0,
}
return l.db.Create(&change).Error
}
// GetPendingChanges returns all pending changes ordered by creation time
func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Order("created_at ASC").Find(&changes).Error
return changes, err
}
// GetPendingChangesByEntity returns pending changes for a specific entity
func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID).
Order("created_at ASC").Find(&changes).Error
return changes, err
}
// DeletePendingChange removes a change from the sync queue after successful sync
func (l *LocalDB) DeletePendingChange(id int64) error {
return l.db.Delete(&PendingChange{}, id).Error
}
// IncrementPendingChangeAttempts updates the attempt counter and last error
func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error {
return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{
"attempts": gorm.Expr("attempts + 1"),
"last_error": errorMsg,
}).Error
}
// CountPendingChanges returns the total number of pending changes
func (l *LocalDB) CountPendingChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Count(&count)
return count
}
// CountPendingChangesByType returns the number of pending changes by entity type
func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
var count int64
l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count)
return count
}
// CountErroredChanges returns the number of pending changes with errors
func (l *LocalDB) CountErroredChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
return count
}
// MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
if len(ids) == 0 {
return nil
}
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
}
// PurgeOrphanConfigurationPendingChanges removes configuration pending changes
// whose entity_uuid no longer exists in local_configurations.
func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
tx := l.db.Where(
"entity_type = ? AND entity_uuid NOT IN (SELECT uuid FROM local_configurations)",
"configuration",
).Delete(&PendingChange{})
return tx.RowsAffected, tx.Error
}
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()
}
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
var erroredChanges []PendingChange
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
}
if len(erroredChanges) == 0 {
return 0, nil, nil
}
repaired := 0
var remainingErrors []string
for _, change := range erroredChanges {
var repairErr error
switch change.EntityType {
case "project":
repairErr = l.repairProjectChange(&change)
case "configuration":
repairErr = l.repairConfigurationChange(&change)
default:
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
}
if repairErr != nil {
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
continue
}
// Clear error and reset attempts
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
"last_error": "",
"attempts": 0,
}).Error; err != nil {
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
continue
}
repaired++
}
return repaired, remainingErrors, nil
}
// repairProjectChange validates and fixes project data.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
project, err := l.GetProjectByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("project not found locally: %w", err)
}
modified := false
// Fix Code: must be non-empty
if strings.TrimSpace(project.Code) == "" {
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
project.Code = strings.TrimSpace(*project.Name)
} else {
project.Code = project.UUID[:8]
}
modified = true
}
// Fix Name: use Code if empty
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
name := project.Code
project.Name = &name
modified = true
}
// Fix OwnerUsername: must be non-empty
if strings.TrimSpace(project.OwnerUsername) == "" {
project.OwnerUsername = l.GetDBUser()
if project.OwnerUsername == "" {
return fmt.Errorf("cannot determine owner username")
}
modified = true
}
// Check for local duplicates with same (code, variant)
var duplicate LocalProject
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
First(&duplicate).Error
if err == nil {
// Found local duplicate - deduplicate by appending UUID suffix to variant
if project.Variant == "" {
project.Variant = project.UUID[:8]
} else {
project.Variant = project.Variant + "-" + project.UUID[:8]
}
modified = true
}
if modified {
if err := l.SaveProject(project); err != nil {
return fmt.Errorf("saving repaired project: %w", err)
}
}
return nil
}
// repairConfigurationChange validates and fixes configuration data
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
config, err := l.GetConfigurationByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("configuration not found locally: %w", err)
}
modified := false
// Check if referenced project exists
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
_, err := l.GetProjectByUUID(*config.ProjectUUID)
if err != nil {
// Project doesn't exist locally - use default system project
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
if sysErr != nil {
return fmt.Errorf("getting system project: %w", sysErr)
}
config.ProjectUUID = &systemProject.UUID
modified = true
}
}
if modified {
if err := l.SaveConfiguration(config); err != nil {
return fmt.Errorf("saving repaired configuration: %w", err)
}
}
return nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
state := &LocalSyncGuardState{
ID: 1,
Status: status,
ReasonCode: reasonCode,
ReasonText: reasonText,
RequiredMinAppVersion: requiredMinAppVersion,
LastCheckedAt: checkedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"status": status,
"reason_code": reasonCode,
"reason_text": reasonText,
"required_min_app_version": requiredMinAppVersion,
"last_checked_at": checkedAt,
"updated_at": time.Now(),
}),
}).Create(state).Error
}