Files
QuoteForge/internal/services/local_configuration.go
T
Mikhail ChusavitinandClaude Sonnet 5 d7d4ea74b6 fix: артикул — категории из component universe + видимость нераспознанных токенов
Генерация артикула резолвила lot_category из одного прайслиста конфигурации
(GetLocalLotCategoriesByServerPricelistID), поэтому world-only LOT (напр.
GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE, есть только в world) молча выпадал из
артикула. Теперь категории берутся через GetLocalComponentCategoriesByLotNames
(тот же world ∪ estimate, что и весь конфигуратор). BuildOptions.ServerPricelist
убран; preview-article принимает pricelist_id, но игнорирует.

Нераспознанные токены больше не пишутся как UNK: в артикул идёт lot_category как
плейсхолдер (4xGPU, 2xCPU), сегмент помечается Recognized=false, добавляется
warning с именем LOT. Конфигуратор подсвечивает такие сегменты (amber) и выводит
список предупреждений; сохранение/обновление/откат логируют WARN. Без каталога
вендоров в репо детектируется только структурный сбой формы имени.

parseGPUModel: принимает суффикс-букву в номере модели (6000D → RTX6000D),
раньше терял её и схлопывал до RTX_84GB.

ADL bible-local/decisions/2026-09-01-article-category-from-component-universe.md,
2026-09-01-article-degraded-token-visibility.md; раздел «Article generation» в
02-architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAhfF4P1ySRZ67yyUUeVw2
2026-09-01 09:01:56 +03:00

1862 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"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, trigger pricelist sync in the background — do not block config creation
if s.isOnline() {
go func() {
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,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
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,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
CreatedAt: time.Now(),
}
if cfg.ConfigType == "" {
cfg.ConfigType = "server"
}
// 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
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,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
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.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
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) {
// Categories for the article come from the component universe (world estimate),
// not from a specific pricelist, so req.PricelistID is no longer needed here.
return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
})
}
// 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,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
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.
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
}
// Use the pricelist stored in the config; fall back to latest if unavailable.
var pricelist *localdb.LocalPricelist
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if pricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.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 pricelist != nil {
localCfg.PricelistID = &pricelist.ServerID
}
// Set price update timestamp and mark for sync
now := time.Now()
localCfg.PriceUpdatedAt = &now
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
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,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
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.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
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,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
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.
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
// Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
}
// Resolve which pricelist to use:
// 1. Explicitly requested pricelist (from UI selection)
// 2. Pricelist stored in the configuration
// 3. Latest local pricelist as last-resort fallback
var targetServerID *uint
if pricelistServerID != nil && *pricelistServerID > 0 {
targetServerID = pricelistServerID
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
targetServerID = localCfg.PricelistID
}
var pricelist *localdb.LocalPricelist
if targetServerID != nil {
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
// Fallback: use latest local pricelist
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if pricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.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 pricelist != nil {
localCfg.PricelistID = &pricelist.ServerID
}
// Set price update timestamp and mark for sync
now := time.Now()
localCfg.PriceUpdatedAt = &now
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
}
return cfg, nil
}
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
// Called before a client-side price refresh so the revision history has a clear before/after.
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.snapshotPreRefreshTx(localCfg, "")
}
// 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 {
return s.createWithVersionTx(tx, localCfg, createdBy)
})
}
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) 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.IsActive != next.IsActive ||
current.Line != next.Line {
return true
}
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true
}
return false
}
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.VendorSpec = spec
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
}
return cfg, nil
}
// RentalUpdateRequest carries the editable fields of a configuration's
// Аренда (rental) tab. Persisted independently of the BOM/pricing tabs.
type RentalUpdateRequest struct {
Items []models.RentalItemCondition `json:"items"`
UpliftPercent float64 `json:"uplift_percent"`
}
// UpdateRentalNoAuth updates only the rental fields of a configuration without ownership check.
func (s *LocalConfigurationService) UpdateRentalNoAuth(uuid string, req *RentalUpdateRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
conditions := make(localdb.RentalItemConditions, 0, len(req.Items))
for _, item := range req.Items {
conditions = append(conditions, localdb.RentalItemCondition{
LotName: models.NormalizeLotName(item.LotName),
Condition: item.Condition,
})
}
localCfg.RentalItems = conditions
localCfg.RentalUpliftPercent = req.UpliftPercent
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update rental fields without auth with version: %w", err)
}
return cfg, nil
}
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.Items = items
total := items.Total()
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
}
return cfg, nil
}
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(&current).Error; err == nil {
return &current, 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) {
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
}
func (s *LocalConfigurationService) appendVersionTxNote(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
noteOverride 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)
if noteOverride != "" {
changeNote = noteOverride
}
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)
}
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
// refresh so the history clearly shows what existed before prices were updated.
// Called only when prices are about to change (fingerprints differ).
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
return 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 row for pre-refresh snapshot: %w", err)
}
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
if err != nil {
return fmt.Errorf("append pre-refresh 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 for pre-refresh snapshot: %w", err)
}
return nil
})
}
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(&current).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.ServerModel = rollbackData.ServerModel
current.SupportCode = rollbackData.SupportCode
current.Article = rollbackData.Article
current.PricelistID = rollbackData.PricelistID
current.WarehousePricelistID = rollbackData.WarehousePricelistID
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
current.VendorSpec = rollbackData.VendorSpec
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(&current).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(&current)
if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "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
}
}
}
// Pricelist not found even after sync — fall back to the latest active one.
slog.Warn("pricelist not available locally, falling back to latest active", "server_pricelist_id", *pricelistID)
}
latest, err := s.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, nil
}
return &latest.ServerID, nil
}