- 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>
1576 lines
49 KiB
Go
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: ¬e,
|
|
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
|
|
}
|