package services import ( "encoding/json" "errors" "fmt" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/article" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) var ( ErrConfigVersionNotFound = errors.New("configuration version not found") ErrInvalidVersionNumber = errors.New("invalid version number") ErrVersionConflict = errors.New("configuration version conflict") ) // LocalConfigurationService handles configurations in local-first mode // All operations go through SQLite, MariaDB is used only for sync type LocalConfigurationService struct { localDB *localdb.LocalDB syncService *sync.Service quoteService *QuoteService isOnline func() bool // Function to check if we're online } // NewLocalConfigurationService creates a new local-first configuration service func NewLocalConfigurationService( localDB *localdb.LocalDB, syncService *sync.Service, quoteService *QuoteService, isOnline func() bool, ) *LocalConfigurationService { return &LocalConfigurationService{ localDB: localDB, syncService: syncService, quoteService: quoteService, isOnline: isOnline, } } // Create creates a new configuration in local SQLite and queues it for sync func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { // If online, check for new pricelists first if s.isOnline() { if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { // Log but don't fail - we can still use local pricelists } } projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID) if err != nil { return nil, err } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { return nil, err } if strings.TrimSpace(req.ServerModel) != "" { articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ ServerModel: req.ServerModel, SupportCode: req.SupportCode, ServerPricelist: pricelistID, }) if articleErr != nil { return nil, articleErr } req.Article = articleResult.Article } total := req.Items.Total() if req.ServerCount > 1 { total *= float64(req.ServerCount) } cfg := &models.Configuration{ UUID: uuid.New().String(), OwnerUsername: ownerUsername, ProjectUUID: projectUUID, Name: req.Name, Items: req.Items, TotalPrice: &total, CustomPrice: req.CustomPrice, Notes: req.Notes, IsTemplate: req.IsTemplate, ServerCount: req.ServerCount, ServerModel: req.ServerModel, SupportCode: req.SupportCode, Article: req.Article, PricelistID: pricelistID, OnlyInStock: req.OnlyInStock, CreatedAt: time.Now(), } // Convert to local model localCfg := localdb.ConfigurationToLocal(cfg) if err := s.createWithVersion(localCfg, ownerUsername); err != nil { return nil, fmt.Errorf("create configuration with version: %w", err) } // Record usage stats _ = s.quoteService.RecordUsage(req.Items) return cfg, nil } // GetByUUID returns a configuration from local SQLite func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !localCfg.IsActive { return nil, ErrConfigNotFound } // Convert to models.Configuration cfg := localdb.LocalToConfiguration(localCfg) // Allow access if user owns config or it's a template if !s.isOwner(localCfg, ownerUsername) && !cfg.IsTemplate { return nil, ErrConfigForbidden } return cfg, nil } // Update updates a configuration in local SQLite and queues it for sync func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(localCfg, ownerUsername) { return nil, ErrConfigForbidden } projectUUID := localCfg.ProjectUUID if req.ProjectUUID != nil { projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID) if err != nil { return nil, err } } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { return nil, err } if strings.TrimSpace(req.ServerModel) != "" { articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ ServerModel: req.ServerModel, SupportCode: req.SupportCode, ServerPricelist: pricelistID, }) if articleErr != nil { return nil, articleErr } req.Article = articleResult.Article } total := req.Items.Total() if req.ServerCount > 1 { total *= float64(req.ServerCount) } // Update fields localCfg.Name = req.Name localCfg.ProjectUUID = projectUUID localCfg.Items = localdb.LocalConfigItems{} for _, item := range req.Items { localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: item.UnitPrice, }) } localCfg.TotalPrice = &total localCfg.CustomPrice = req.CustomPrice localCfg.Notes = req.Notes localCfg.IsTemplate = req.IsTemplate localCfg.ServerCount = req.ServerCount localCfg.ServerModel = req.ServerModel localCfg.SupportCode = req.SupportCode localCfg.Article = req.Article localCfg.PricelistID = pricelistID localCfg.OnlyInStock = req.OnlyInStock localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername) if err != nil { return nil, fmt.Errorf("update configuration with version: %w", err) } return cfg, nil } // BuildArticlePreview generates server article based on current items and server_model/support_code. func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) { pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { return article.BuildResult{}, err } return article.Build(s.localDB, req.Items, article.BuildOptions{ ServerModel: req.ServerModel, SupportCode: req.SupportCode, ServerPricelist: pricelistID, }) } // Delete deletes a configuration from local SQLite and queues it for sync func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return ErrConfigNotFound } if !s.isOwner(localCfg, ownerUsername) { return ErrConfigForbidden } localCfg.IsActive = false localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" _, err = s.saveWithVersionAndPending(localCfg, "deactivate", ownerUsername) return err } // Reactivate restores an archived configuration and creates a new version. func (s *LocalConfigurationService) Reactivate(uuid string, ownerUsername string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(localCfg, ownerUsername) { return nil, ErrConfigForbidden } if localCfg.IsActive { return localdb.LocalToConfiguration(localCfg), nil } localCfg.IsActive = true localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" return s.saveWithVersionAndPending(localCfg, "reactivate", ownerUsername) } // Rename renames a configuration func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(localCfg, ownerUsername) { return nil, ErrConfigForbidden } localCfg.Name = newName localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername) if err != nil { return nil, fmt.Errorf("rename configuration with version: %w", err) } return cfg, nil } // Clone clones a configuration func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) { return s.CloneToProject(configUUID, ownerUsername, newName, nil) } func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) { original, err := s.GetByUUID(configUUID, ownerUsername) if err != nil { return nil, err } resolvedProjectUUID := original.ProjectUUID if projectUUID != nil { resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID) if err != nil { return nil, err } } total := original.Items.Total() if original.ServerCount > 1 { total *= float64(original.ServerCount) } clone := &models.Configuration{ UUID: uuid.New().String(), OwnerUsername: ownerUsername, ProjectUUID: resolvedProjectUUID, Name: newName, Items: original.Items, TotalPrice: &total, CustomPrice: original.CustomPrice, Notes: original.Notes, IsTemplate: false, ServerCount: original.ServerCount, ServerModel: original.ServerModel, SupportCode: original.SupportCode, Article: original.Article, PricelistID: original.PricelistID, OnlyInStock: original.OnlyInStock, CreatedAt: time.Now(), } localCfg := localdb.ConfigurationToLocal(clone) if err := s.createWithVersion(localCfg, ownerUsername); err != nil { return nil, fmt.Errorf("clone configuration with version: %w", err) } return clone, nil } // ListByUser returns all configurations for a user from local SQLite func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) { return s.listByUserWithStatus(ownerUsername, page, perPage, "active") } func (s *LocalConfigurationService) listByUserWithStatus(ownerUsername string, page, perPage int, status string) ([]models.Configuration, int64, error) { // Get all local configurations localConfigs, err := s.localDB.GetConfigurations() if err != nil { return nil, 0, err } // Filter by user var userConfigs []models.Configuration for _, lc := range localConfigs { if !matchesConfigStatus(lc.IsActive, status) { continue } if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate { userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc)) } } total := int64(len(userConfigs)) // Apply pagination if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage start := offset if start > len(userConfigs) { start = len(userConfigs) } end := start + perPage if end > len(userConfigs) { end = len(userConfigs) } return userConfigs[start:end], total, nil } // RefreshPrices updates all component prices in the configuration from local cache func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) { // Get configuration from local SQLite localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } // Check ownership if !s.isOwner(localCfg, ownerUsername) { return nil, ErrConfigForbidden } // Refresh local pricelists when online and use latest active/local pricelist for recalculation. if s.isOnline() { _ = s.syncService.SyncPricelistsIfNeeded() } latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() // Update prices for all items from pricelist updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) for i, item := range localCfg.Items { if latestErr == nil && latestPricelist != nil { price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName) if err == nil && price > 0 { updatedItems[i] = localdb.LocalConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: price, } continue } } // Keep original item if price not found in pricelist updatedItems[i] = item } // Update configuration localCfg.Items = updatedItems total := updatedItems.Total() // If server count is greater than 1, multiply the total by server count if localCfg.ServerCount > 1 { total *= float64(localCfg.ServerCount) } localCfg.TotalPrice = &total if latestErr == nil && latestPricelist != nil { localCfg.PricelistID = &latestPricelist.ServerID } // Set price update timestamp and mark for sync now := time.Now() localCfg.PriceUpdatedAt = &now localCfg.UpdatedAt = now localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername) if err != nil { return nil, fmt.Errorf("refresh prices with version: %w", err) } return cfg, nil } // GetByUUIDNoAuth returns configuration without ownership check func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !localCfg.IsActive { return nil, ErrConfigNotFound } return localdb.LocalToConfiguration(localCfg), nil } // UpdateNoAuth updates configuration without ownership check func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } projectUUID := localCfg.ProjectUUID if req.ProjectUUID != nil { projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID) if err != nil { return nil, err } } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { return nil, err } if strings.TrimSpace(req.ServerModel) != "" { articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ ServerModel: req.ServerModel, SupportCode: req.SupportCode, ServerPricelist: pricelistID, }) if articleErr != nil { return nil, articleErr } req.Article = articleResult.Article } total := req.Items.Total() if req.ServerCount > 1 { total *= float64(req.ServerCount) } localCfg.Name = req.Name localCfg.ProjectUUID = projectUUID localCfg.Items = localdb.LocalConfigItems{} for _, item := range req.Items { localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: item.UnitPrice, }) } localCfg.TotalPrice = &total localCfg.CustomPrice = req.CustomPrice localCfg.Notes = req.Notes localCfg.IsTemplate = req.IsTemplate localCfg.ServerCount = req.ServerCount localCfg.ServerModel = req.ServerModel localCfg.SupportCode = req.SupportCode localCfg.Article = req.Article localCfg.PricelistID = pricelistID localCfg.OnlyInStock = req.OnlyInStock localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") if err != nil { return nil, fmt.Errorf("update configuration without auth with version: %w", err) } return cfg, nil } // DeleteNoAuth deletes configuration without ownership check func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return ErrConfigNotFound } localCfg.IsActive = false localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" _, err = s.saveWithVersionAndPending(localCfg, "deactivate", "") return err } // ReactivateNoAuth restores an archived configuration without ownership check. func (s *LocalConfigurationService) ReactivateNoAuth(uuid string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if localCfg.IsActive { return localdb.LocalToConfiguration(localCfg), nil } localCfg.IsActive = true localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" return s.saveWithVersionAndPending(localCfg, "reactivate", "") } // RenameNoAuth renames configuration without ownership check func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } localCfg.Name = newName localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") if err != nil { return nil, fmt.Errorf("rename configuration without auth with version: %w", err) } return cfg, nil } // CloneNoAuth clones configuration without ownership check func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) { return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil) } func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) { original, err := s.GetByUUIDNoAuth(configUUID) if err != nil { return nil, err } resolvedProjectUUID := original.ProjectUUID if projectUUID != nil { resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID) if err != nil { return nil, err } } total := original.Items.Total() if original.ServerCount > 1 { total *= float64(original.ServerCount) } clone := &models.Configuration{ UUID: uuid.New().String(), OwnerUsername: ownerUsername, ProjectUUID: resolvedProjectUUID, Name: newName, Items: original.Items, TotalPrice: &total, CustomPrice: original.CustomPrice, Notes: original.Notes, IsTemplate: false, ServerCount: original.ServerCount, PricelistID: original.PricelistID, OnlyInStock: original.OnlyInStock, CreatedAt: time.Now(), } localCfg := localdb.ConfigurationToLocal(clone) if err := s.createWithVersion(localCfg, ownerUsername); err != nil { return nil, fmt.Errorf("clone configuration without auth with version: %w", err) } return clone, nil } // SetProjectNoAuth moves configuration to a different project without ownership check. func (s *LocalConfigurationService) SetProjectNoAuth(uuid string, projectUUID string) (*models.Configuration, error) { localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } var resolved *string trimmed := strings.TrimSpace(projectUUID) if trimmed == "" { resolved, err = s.resolveProjectUUID(localCfg.OriginalUsername, &projectUUID) if err != nil { return nil, err } } else { project, getErr := s.localDB.GetProjectByUUID(trimmed) if getErr != nil { return nil, ErrProjectNotFound } if !project.IsActive { return nil, errors.New("project is archived") } resolved = &project.UUID } localCfg.ProjectUUID = resolved localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" return s.saveWithVersionAndPending(localCfg, "update", "") } // ListAll returns all configurations without user filter func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) { return s.ListAllWithStatus(page, perPage, "active", "") } // ListAllWithStatus returns configurations filtered by status: active|archived|all. func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) { // Apply pagination if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage) if err != nil { return nil, 0, err } configs := make([]models.Configuration, 0, len(localConfigs)) for _, lc := range localConfigs { configs = append(configs, *localdb.LocalToConfiguration(&lc)) } return configs, total, nil } // ListTemplates returns all template configurations func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) { localConfigs, err := s.localDB.GetConfigurations() if err != nil { return nil, 0, err } var templates []models.Configuration for _, lc := range localConfigs { if !lc.IsActive { continue } if lc.IsTemplate { templates = append(templates, *localdb.LocalToConfiguration(&lc)) } } total := int64(len(templates)) // Apply pagination if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage start := offset if start > len(templates) { start = len(templates) } end := start + perPage if end > len(templates) { end = len(templates) } return templates[start:end], total, nil } // RefreshPricesNoAuth updates all component prices in the configuration without ownership check func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { // Get configuration from local SQLite localCfg, err := s.localDB.GetConfigurationByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if s.isOnline() { _ = s.syncService.SyncPricelistsIfNeeded() } latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() // Update prices for all items from pricelist updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) for i, item := range localCfg.Items { if latestErr == nil && latestPricelist != nil { price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName) if err == nil && price > 0 { updatedItems[i] = localdb.LocalConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: price, } continue } } // Keep original item if price not found in pricelist updatedItems[i] = item } // Update configuration localCfg.Items = updatedItems total := updatedItems.Total() // If server count is greater than 1, multiply the total by server count if localCfg.ServerCount > 1 { total *= float64(localCfg.ServerCount) } localCfg.TotalPrice = &total if latestErr == nil && latestPricelist != nil { localCfg.PricelistID = &latestPricelist.ServerID } // Set price update timestamp and mark for sync now := time.Now() localCfg.PriceUpdatedAt = &now localCfg.UpdatedAt = now localCfg.SyncStatus = "pending" cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") if err != nil { return nil, fmt.Errorf("refresh prices without auth with version: %w", err) } return cfg, nil } // ImportFromServer imports configurations from MariaDB to local SQLite cache. func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) { return s.syncService.ImportConfigurationsToLocal() } // GetCurrentVersion returns the currently active version row for configuration UUID. func (s *LocalConfigurationService) GetCurrentVersion(configurationUUID string) (*localdb.LocalConfigurationVersion, error) { var cfg localdb.LocalConfiguration if err := s.localDB.DB().Where("uuid = ?", configurationUUID).First(&cfg).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConfigNotFound } return nil, fmt.Errorf("get configuration for current version: %w", err) } var version localdb.LocalConfigurationVersion if cfg.CurrentVersionID != nil && *cfg.CurrentVersionID != "" { if err := s.localDB.DB(). Where("id = ? AND configuration_uuid = ?", *cfg.CurrentVersionID, configurationUUID). First(&version).Error; err == nil { return &version, nil } } if err := s.localDB.DB(). Where("configuration_uuid = ?", configurationUUID). Order("version_no DESC"). First(&version).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConfigVersionNotFound } return nil, fmt.Errorf("get latest version for current pointer fallback: %w", err) } return &version, nil } // ListVersions returns versions by configuration UUID in descending order by version number. func (s *LocalConfigurationService) ListVersions(configurationUUID string, limit, offset int) ([]localdb.LocalConfigurationVersion, error) { if limit <= 0 { limit = 20 } if limit > 200 { limit = 200 } if offset < 0 { return nil, ErrInvalidVersionNumber } var cfgCount int64 if err := s.localDB.DB().Model(&localdb.LocalConfiguration{}). Where("uuid = ?", configurationUUID). Count(&cfgCount).Error; err != nil { return nil, fmt.Errorf("check configuration before list versions: %w", err) } if cfgCount == 0 { return nil, ErrConfigNotFound } var versions []localdb.LocalConfigurationVersion if err := s.localDB.DB(). Where("configuration_uuid = ?", configurationUUID). Order("version_no DESC"). Limit(limit). Offset(offset). Find(&versions).Error; err != nil { return nil, fmt.Errorf("list versions: %w", err) } return versions, nil } // GetVersion returns one version by configuration UUID and version number. func (s *LocalConfigurationService) GetVersion(configurationUUID string, versionNo int) (*localdb.LocalConfigurationVersion, error) { if versionNo <= 0 { return nil, ErrInvalidVersionNumber } var version localdb.LocalConfigurationVersion if err := s.localDB.DB(). Where("configuration_uuid = ? AND version_no = ?", configurationUUID, versionNo). First(&version).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrConfigVersionNotFound } return nil, fmt.Errorf("get version %d for %s: %w", versionNo, configurationUUID, err) } return &version, nil } // RollbackToVersion creates a new version from target snapshot and marks it current. func (s *LocalConfigurationService) RollbackToVersion(configurationUUID string, targetVersionNo int, userID string) (*models.Configuration, error) { return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, "") } // RollbackToVersionWithNote same as RollbackToVersion, with optional user note. func (s *LocalConfigurationService) RollbackToVersionWithNote(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) { return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, note) } func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool { if cfg == nil || ownerUsername == "" { return false } if cfg.OriginalUsername != "" { return cfg.OriginalUsername == ownerUsername } return false } func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error { return s.localDB.DB().Transaction(func(tx *gorm.DB) error { if err := tx.Create(localCfg).Error; err != nil { return fmt.Errorf("create local configuration: %w", err) } version, err := s.appendVersionTx(tx, localCfg, "create", createdBy) if err != nil { return fmt.Errorf("append create version: %w", err) } if err := tx.Model(&localdb.LocalConfiguration{}). Where("uuid = ?", localCfg.UUID). Update("current_version_id", version.ID).Error; err != nil { return fmt.Errorf("set current version id: %w", err) } localCfg.CurrentVersionID = &version.ID if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil { return fmt.Errorf("enqueue create pending change: %w", err) } if err := s.recalculateLocalPricelistUsageTx(tx); err != nil { return fmt.Errorf("recalculate local pricelist usage: %w", err) } return nil }) } func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) { var cfg *models.Configuration err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { var locked localdb.LocalConfiguration if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("uuid = ?", localCfg.UUID). First(&locked).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrConfigNotFound } return fmt.Errorf("lock configuration row: %w", err) } if err := tx.Save(localCfg).Error; err != nil { return fmt.Errorf("save local configuration: %w", err) } version, err := s.appendVersionTx(tx, localCfg, operation, createdBy) if err != nil { return fmt.Errorf("append %s version: %w", operation, err) } if err := tx.Model(&localdb.LocalConfiguration{}). Where("uuid = ?", localCfg.UUID). Update("current_version_id", version.ID).Error; err != nil { return fmt.Errorf("update current version id: %w", err) } localCfg.CurrentVersionID = &version.ID cfg = localdb.LocalToConfiguration(localCfg) if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil { return fmt.Errorf("enqueue %s pending change: %w", operation, err) } if err := s.recalculateLocalPricelistUsageTx(tx); err != nil { return fmt.Errorf("recalculate local pricelist usage: %w", err) } return nil }) if err != nil { return nil, err } return cfg, nil } func (s *LocalConfigurationService) appendVersionTx( tx *gorm.DB, localCfg *localdb.LocalConfiguration, operation string, createdBy string, ) (*localdb.LocalConfigurationVersion, error) { snapshot, err := s.buildConfigurationSnapshot(localCfg) if err != nil { return nil, fmt.Errorf("build snapshot: %w", err) } changeNote := fmt.Sprintf("%s via local-first flow", operation) var createdByPtr *string if createdBy != "" { createdByPtr = &createdBy } for attempt := 0; attempt < 3; attempt++ { var maxVersion int if err := tx.Model(&localdb.LocalConfigurationVersion{}). Where("configuration_uuid = ?", localCfg.UUID). Select("COALESCE(MAX(version_no), 0)"). Scan(&maxVersion).Error; err != nil { return nil, fmt.Errorf("read max version: %w", err) } versionID := uuid.New().String() version := &localdb.LocalConfigurationVersion{ ID: versionID, ConfigurationUUID: localCfg.UUID, VersionNo: maxVersion + 1, Data: snapshot, ChangeNote: &changeNote, CreatedBy: createdByPtr, AppVersion: appmeta.Version(), } if err := tx.Create(version).Error; err != nil { // SQLite equivalent safety: serialized writer tx + UNIQUE(configuration_uuid, version_no) + retry. if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") { continue } return nil, fmt.Errorf("insert configuration version: %w", err) } return version, nil } return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID) } func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) { return localdb.BuildConfigurationSnapshot(localCfg) } func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) { if targetVersionNo <= 0 { return nil, ErrInvalidVersionNumber } var resultCfg *models.Configuration err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { var current localdb.LocalConfiguration if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("uuid = ?", configurationUUID). First(¤t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrConfigNotFound } return fmt.Errorf("lock configuration for rollback: %w", err) } var target localdb.LocalConfigurationVersion if err := tx.Where("configuration_uuid = ? AND version_no = ?", configurationUUID, targetVersionNo). First(&target).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrConfigVersionNotFound } return fmt.Errorf("load target rollback version: %w", err) } rollbackData, err := s.decodeConfigurationSnapshot(target.Data) if err != nil { return fmt.Errorf("decode target rollback snapshot: %w", err) } // Keep stable identity/sync linkage; restore editable config content from target snapshot. current.Name = rollbackData.Name current.Items = rollbackData.Items current.TotalPrice = rollbackData.TotalPrice current.CustomPrice = rollbackData.CustomPrice current.Notes = rollbackData.Notes current.IsTemplate = rollbackData.IsTemplate current.ServerCount = rollbackData.ServerCount current.PricelistID = rollbackData.PricelistID current.OnlyInStock = rollbackData.OnlyInStock current.PriceUpdatedAt = rollbackData.PriceUpdatedAt current.UpdatedAt = time.Now() current.SyncStatus = "pending" current.IsActive = rollbackData.IsActive if rollbackData.OriginalUsername != "" { current.OriginalUsername = rollbackData.OriginalUsername } if rollbackData.OriginalUserID != 0 { current.OriginalUserID = rollbackData.OriginalUserID } if err := tx.Save(¤t).Error; err != nil { return fmt.Errorf("save rolled back configuration: %w", err) } var maxVersion int if err := tx.Model(&localdb.LocalConfigurationVersion{}). Where("configuration_uuid = ?", configurationUUID). Select("COALESCE(MAX(version_no), 0)"). Scan(&maxVersion).Error; err != nil { return fmt.Errorf("read max version before rollback append: %w", err) } changeNote := fmt.Sprintf("rollback to v%d", targetVersionNo) if trimmed := strings.TrimSpace(note); trimmed != "" { changeNote = fmt.Sprintf("%s (%s)", changeNote, trimmed) } version := &localdb.LocalConfigurationVersion{ ID: uuid.New().String(), ConfigurationUUID: configurationUUID, VersionNo: maxVersion + 1, Data: target.Data, ChangeNote: &changeNote, CreatedBy: stringPtrOrNil(userID), AppVersion: appmeta.Version(), } if err := tx.Create(version).Error; err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") { return ErrVersionConflict } return fmt.Errorf("create rollback version: %w", err) } if err := tx.Model(&localdb.LocalConfiguration{}). Where("uuid = ?", configurationUUID). Update("current_version_id", version.ID).Error; err != nil { return fmt.Errorf("update current version after rollback: %w", err) } current.CurrentVersionID = &version.ID resultCfg = localdb.LocalToConfiguration(¤t) if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil { return fmt.Errorf("enqueue rollback pending change: %w", err) } if err := s.recalculateLocalPricelistUsageTx(tx); err != nil { return fmt.Errorf("recalculate local pricelist usage: %w", err) } return nil }) if err != nil { return nil, err } return resultCfg, nil } func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx( tx *gorm.DB, localCfg *localdb.LocalConfiguration, operation string, version *localdb.LocalConfigurationVersion, createdBy string, ) error { cfg := localdb.LocalToConfiguration(localCfg) payload := sync.ConfigurationChangePayload{ EventID: uuid.New().String(), IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation), ConfigurationUUID: localCfg.UUID, ProjectUUID: localCfg.ProjectUUID, PricelistID: localCfg.PricelistID, Operation: operation, CurrentVersionID: version.ID, CurrentVersionNo: version.VersionNo, ConflictPolicy: "last_write_wins", Snapshot: *cfg, CreatedAt: time.Now().UTC(), CreatedBy: stringPtrOrNil(createdBy), } rawPayload, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal pending payload: %w", err) } change := &localdb.PendingChange{ EntityType: "configuration", EntityUUID: localCfg.UUID, Operation: operation, Payload: string(rawPayload), CreatedAt: time.Now(), Attempts: 0, } if err := tx.Create(change).Error; err != nil { return fmt.Errorf("insert pending change: %w", err) } return nil } func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*localdb.LocalConfiguration, error) { return localdb.DecodeConfigurationSnapshot(data) } func (s *LocalConfigurationService) recalculateLocalPricelistUsageTx(tx *gorm.DB) error { if err := tx.Model(&localdb.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 } func stringPtrOrNil(value string) *string { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } return &trimmed } func matchesConfigStatus(isActive bool, status string) bool { switch status { case "active", "": return isActive case "archived": return !isActive case "all": return true default: return isActive } } func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) { if ownerUsername == "" { ownerUsername = s.localDB.GetDBUser() } if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" { project, err := s.localDB.EnsureDefaultProject(ownerUsername) if err != nil { return nil, err } return &project.UUID, nil } requested := strings.TrimSpace(*projectUUID) project, err := s.localDB.GetProjectByUUID(requested) if err != nil { return nil, ErrProjectNotFound } if !project.IsActive { return nil, errors.New("project is archived") } return &project.UUID, nil } func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) { if pricelistID != nil && *pricelistID > 0 { if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil { return pricelistID, nil } if s.isOnline() { if _, err := s.syncService.SyncPricelists(); err == nil { if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil { return pricelistID, nil } } } return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID) } latest, err := s.localDB.GetLatestLocalPricelist() if err != nil { return nil, nil } return &latest.ServerID, nil }