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) } cfg.Line = localCfg.Line // 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) } clone.Line = localCfg.Line 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) { var localCfg localdb.LocalConfiguration if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; 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 { requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID) currentProjectUUID := "" if localCfg.ProjectUUID != nil { currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID) } projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID) if err != nil { // Allow save for legacy/orphaned configs when request keeps the same project UUID. // This can happen for imported configs whose project is not present in local cache. if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID { projectUUID = localCfg.ProjectUUID } else { 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) { return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0) } func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) { original, err := s.GetByUUIDNoAuth(configUUID) if err != nil { return nil, err } // If fromVersion specified, use snapshot from that version if fromVersion > 0 { version, vErr := s.GetVersion(configUUID, fromVersion) if vErr != nil { return nil, vErr } snapshot, decErr := s.decodeConfigurationSnapshot(version.Data) if decErr != nil { return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr) } snapshotCfg := localdb.LocalToConfiguration(snapshot) original = snapshotCfg original.UUID = configUUID // preserve original UUID for project resolution } 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) } clone.Line = localCfg.Line 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 } // UpdateServerCount updates server count and recalculates total price without creating a new version. func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) { if serverCount < 1 { return nil, fmt.Errorf("server count must be at least 1") } localCfg, err := s.localDB.GetConfigurationByUUID(configUUID) if err != nil { return nil, ErrConfigNotFound } localCfg.ServerCount = serverCount total := localCfg.Items.Total() if serverCount > 1 { total *= float64(serverCount) } localCfg.TotalPrice = &total localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" var cfg *models.Configuration err = s.localDB.DB().Transaction(func(tx *gorm.DB) error { if err := tx.Save(localCfg).Error; err != nil { return fmt.Errorf("save local configuration: %w", err) } version, err := s.loadVersionForPendingTx(tx, localCfg) if err != nil { return err } cfg = localdb.LocalToConfiguration(localCfg) if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil { return fmt.Errorf("enqueue server-count pending change: %w", err) } return nil }) if err != nil { return nil, err } return cfg, nil } func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) { projectUUID = strings.TrimSpace(projectUUID) if projectUUID == "" { return nil, ErrProjectNotFound } if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil { return nil, ErrProjectNotFound } if len(orderedUUIDs) == 0 { return []models.Configuration{}, nil } seen := make(map[string]struct{}, len(orderedUUIDs)) normalized := make([]string, 0, len(orderedUUIDs)) for _, raw := range orderedUUIDs { u := strings.TrimSpace(raw) if u == "" { return nil, fmt.Errorf("ordered_uuids contains empty uuid") } if _, exists := seen[u]; exists { return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u) } seen[u] = struct{}{} normalized = append(normalized, u) } err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { var active []localdb.LocalConfiguration if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true). Find(&active).Error; err != nil { return fmt.Errorf("load project active configurations: %w", err) } if len(active) != len(normalized) { return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized)) } byUUID := make(map[string]*localdb.LocalConfiguration, len(active)) for i := range active { cfg := active[i] byUUID[cfg.UUID] = &cfg } for _, id := range normalized { if _, ok := byUUID[id]; !ok { return fmt.Errorf("configuration %s not found in project %s", id, projectUUID) } } now := time.Now() for idx, id := range normalized { cfg := byUUID[id] newLine := (idx + 1) * 10 if cfg.Line == newLine { continue } cfg.Line = newLine cfg.UpdatedAt = now cfg.SyncStatus = "pending" if err := tx.Save(cfg).Error; err != nil { return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err) } version, err := s.loadVersionForPendingTx(tx, cfg) if err != nil { return err } if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil { return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err) } } return nil }) if err != nil { return nil, err } var localConfigs []localdb.LocalConfiguration if err := s.localDB.DB(). Preload("CurrentVersion"). Where("project_uuid = ? AND is_active = ?", projectUUID, true). Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC"). Find(&localConfigs).Error; err != nil { return nil, fmt.Errorf("load reordered configurations: %w", err) } result := make([]models.Configuration, 0, len(localConfigs)) for i := range localConfigs { result = append(result, *localdb.LocalToConfiguration(&localConfigs[i])) } return result, 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 localCfg.IsActive { if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil { return err } } 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 localCfg.CurrentVersion = version 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 operation == "update" { currentVersion, err := s.loadCurrentVersionTx(tx, &locked) if err != nil { return fmt.Errorf("load current version before save: %w", err) } // Legacy/orphaned rows may have empty or stale current_version_id. // In that case we treat update as content-changing and append a fresh version. if currentVersion != nil { sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion) if err != nil { return fmt.Errorf("compare revision content: %w", err) } if sameRevisionContent { if !hasNonRevisionConfigurationChanges(&locked, localCfg) { cfg = localdb.LocalToConfiguration(&locked) return nil } if err := tx.Save(localCfg).Error; err != nil { return fmt.Errorf("save local configuration (no new revision): %w", err) } cfg = localdb.LocalToConfiguration(localCfg) if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil { return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err) } if err := s.recalculateLocalPricelistUsageTx(tx); err != nil { return fmt.Errorf("recalculate local pricelist usage: %w", err) } return nil } } } if localCfg.IsActive { if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil { return 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 localCfg.CurrentVersion = version 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 hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool { if current == nil || next == nil { return true } if current.Name != next.Name || current.Notes != next.Notes || current.IsTemplate != next.IsTemplate || current.ServerModel != next.ServerModel || current.SupportCode != next.SupportCode || current.Article != next.Article || current.OnlyInStock != next.OnlyInStock || current.IsActive != next.IsActive || current.Line != next.Line { return true } if !equalUintPtr(current.PricelistID, next.PricelistID) || !equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) || !equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) || !equalStringPtr(current.ProjectUUID, next.ProjectUUID) { return true } return false } func equalStringPtr(a, b *string) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return strings.TrimSpace(*a) == strings.TrimSpace(*b) } func equalUintPtr(a, b *uint) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) { var version localdb.LocalConfigurationVersion if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" { if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil { return &version, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } if err := tx.Where("configuration_uuid = ?", localCfg.UUID). Order("version_no DESC"). First(&version).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &version, nil } func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) { if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" { var current localdb.LocalConfigurationVersion if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(¤t).Error; err == nil { return ¤t, nil } } var latest localdb.LocalConfigurationVersion if err := tx.Where("configuration_uuid = ?", localCfg.UUID). Order("version_no DESC"). First(&latest).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("load version for pending change: %w", err) } // Legacy/imported rows may exist without local version history. // Bootstrap the first version so pending sync payloads can reference a version. version, createErr := s.appendVersionTx(tx, localCfg, "bootstrap", "") if createErr != nil { return nil, fmt.Errorf("bootstrap version for pending change: %w", createErr) } if err := tx.Model(&localdb.LocalConfiguration{}). Where("uuid = ?", localCfg.UUID). Update("current_version_id", version.ID).Error; err != nil { return nil, fmt.Errorf("set current version id for bootstrapped pending change: %w", err) } localCfg.CurrentVersionID = &version.ID return version, nil } return &latest, nil } func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error { if localCfg == nil || !localCfg.IsActive { return nil } needsAssign := localCfg.Line <= 0 if !needsAssign { query := tx.Model(&localdb.LocalConfiguration{}). Where("is_active = ? AND line_no = ?", true, localCfg.Line) if strings.TrimSpace(localCfg.UUID) != "" { query = query.Where("uuid <> ?", strings.TrimSpace(localCfg.UUID)) } if localCfg.ProjectUUID != nil && strings.TrimSpace(*localCfg.ProjectUUID) != "" { query = query.Where("project_uuid = ?", strings.TrimSpace(*localCfg.ProjectUUID)) } else { query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''") } var conflicts int64 if err := query.Count(&conflicts).Error; err != nil { return fmt.Errorf("check line_no conflict for configuration %s: %w", localCfg.UUID, err) } needsAssign = conflicts > 0 } if needsAssign { line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID) if err != nil { return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err) } localCfg.Line = line } return nil } func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) { currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data) if err != nil { return false, fmt.Errorf("decode current version snapshot: %w", err) } currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg) if err != nil { return false, fmt.Errorf("build current snapshot fingerprint: %w", err) } nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg) if err != nil { return false, fmt.Errorf("build next snapshot fingerprint: %w", err) } return currentFingerprint == nextFingerprint, 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 if rollbackData.Line > 0 { current.Line = rollbackData.Line } 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 }