Генерация артикула резолвила 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
1862 lines
59 KiB
Go
1862 lines
59 KiB
Go
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(¤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) {
|
||
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(¤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.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(¤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
|
||
}
|
||
}
|
||
}
|
||
// 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
|
||
}
|