package services import ( "errors" "time" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "github.com/google/uuid" ) var ( ErrConfigNotFound = errors.New("configuration not found") ErrConfigForbidden = errors.New("access to configuration forbidden") ) // ConfigurationGetter is an interface for services that can retrieve configurations // Used by handlers to work with both ConfigurationService and LocalConfigurationService type ConfigurationGetter interface { GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) } type ConfigurationService struct { configRepo *repository.ConfigurationRepository projectRepo *repository.ProjectRepository componentRepo *repository.ComponentRepository pricelistRepo *repository.PricelistRepository quoteService *QuoteService } func NewConfigurationService( configRepo *repository.ConfigurationRepository, projectRepo *repository.ProjectRepository, componentRepo *repository.ComponentRepository, pricelistRepo *repository.PricelistRepository, quoteService *QuoteService, ) *ConfigurationService { return &ConfigurationService{ configRepo: configRepo, projectRepo: projectRepo, componentRepo: componentRepo, pricelistRepo: pricelistRepo, quoteService: quoteService, } } type CreateConfigRequest struct { Name string `json:"name"` Items models.ConfigItems `json:"items"` ProjectUUID *string `json:"project_uuid,omitempty"` CustomPrice *float64 `json:"custom_price"` Notes string `json:"notes"` IsTemplate bool `json:"is_template"` ServerCount int `json:"server_count"` ServerModel string `json:"server_model,omitempty"` SupportCode string `json:"support_code,omitempty"` Article string `json:"article,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"` OnlyInStock bool `json:"only_in_stock"` } type ArticlePreviewRequest struct { Items models.ConfigItems `json:"items"` ServerModel string `json:"server_model"` SupportCode string `json:"support_code,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"` } func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { 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 } total := req.Items.Total() // If server count is greater than 1, multiply the total by server count if req.ServerCount > 1 { total *= float64(req.ServerCount) } config := &models.Configuration{ UUID: uuid.New().String(), OwnerUsername: ownerUsername, ProjectUUID: projectUUID, Name: req.Name, Items: req.Items, TotalPrice: &total, CustomPrice: req.CustomPrice, Notes: req.Notes, IsTemplate: req.IsTemplate, ServerCount: req.ServerCount, ServerModel: req.ServerModel, SupportCode: req.SupportCode, Article: req.Article, PricelistID: pricelistID, OnlyInStock: req.OnlyInStock, } if err := s.configRepo.Create(config); err != nil { return nil, err } // Record usage stats _ = s.quoteService.RecordUsage(req.Items) return config, nil } func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } // Allow access if user owns config or it's a template if !s.isOwner(config, ownerUsername) && !config.IsTemplate { return nil, ErrConfigForbidden } return config, nil } func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(config, ownerUsername) { return nil, ErrConfigForbidden } 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 } total := req.Items.Total() // If server count is greater than 1, multiply the total by server count if req.ServerCount > 1 { total *= float64(req.ServerCount) } config.Name = req.Name config.ProjectUUID = projectUUID config.Items = req.Items config.TotalPrice = &total config.CustomPrice = req.CustomPrice config.Notes = req.Notes config.IsTemplate = req.IsTemplate config.ServerCount = req.ServerCount config.ServerModel = req.ServerModel config.SupportCode = req.SupportCode config.Article = req.Article config.PricelistID = pricelistID config.OnlyInStock = req.OnlyInStock if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return ErrConfigNotFound } if !s.isOwner(config, ownerUsername) { return ErrConfigForbidden } return s.configRepo.Delete(config.ID) } func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(config, ownerUsername) { return nil, ErrConfigForbidden } config.Name = newName if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) { return s.CloneToProject(configUUID, ownerUsername, newName, nil) } func (s *ConfigurationService) 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 } } // Create copy with new UUID and name total := original.Items.Total() // If server count is greater than 1, multiply the total by server count 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, // Clone is never a template ServerCount: original.ServerCount, PricelistID: original.PricelistID, OnlyInStock: original.OnlyInStock, } if err := s.configRepo.Create(clone); err != nil { return nil, err } return clone, nil } func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) { if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage return s.configRepo.ListByUser(ownerUsername, offset, perPage) } // ListAll returns all configurations without user filter (for use when auth is disabled) func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) { if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage return s.configRepo.ListAll(offset, perPage) } // GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled) func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } return config, nil } // UpdateNoAuth updates configuration without ownership check func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID) if err != nil { return nil, err } pricelistID, err := s.resolvePricelistID(req.PricelistID) if err != nil { return nil, err } total := req.Items.Total() if req.ServerCount > 1 { total *= float64(req.ServerCount) } config.Name = req.Name config.ProjectUUID = projectUUID config.Items = req.Items config.TotalPrice = &total config.CustomPrice = req.CustomPrice config.Notes = req.Notes config.IsTemplate = req.IsTemplate config.ServerCount = req.ServerCount config.PricelistID = pricelistID config.OnlyInStock = req.OnlyInStock if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } // DeleteNoAuth deletes configuration without ownership check func (s *ConfigurationService) DeleteNoAuth(uuid string) error { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return ErrConfigNotFound } return s.configRepo.Delete(config.ID) } // RenameNoAuth renames configuration without ownership check func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } config.Name = newName if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } // CloneNoAuth clones configuration without ownership check func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) { return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil) } func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) { original, err := s.configRepo.GetByUUID(configUUID) if err != nil { return nil, ErrConfigNotFound } resolvedProjectUUID := original.ProjectUUID if projectUUID != nil { resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID) if err != nil { return nil, err } } total := original.Items.Total() if original.ServerCount > 1 { total *= float64(original.ServerCount) } clone := &models.Configuration{ UUID: uuid.New().String(), OwnerUsername: ownerUsername, ProjectUUID: resolvedProjectUUID, Name: newName, Items: original.Items, TotalPrice: &total, CustomPrice: original.CustomPrice, Notes: original.Notes, IsTemplate: false, ServerCount: original.ServerCount, PricelistID: original.PricelistID, OnlyInStock: original.OnlyInStock, } if err := s.configRepo.Create(clone); err != nil { return nil, err } return clone, nil } func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) { _ = ownerUsername if s.projectRepo == nil { return projectUUID, nil } if projectUUID == nil || *projectUUID == "" { return nil, nil } project, err := s.projectRepo.GetByUUID(*projectUUID) if err != nil { return nil, ErrProjectNotFound } if !project.IsActive { return nil, errors.New("project is archived") } return &project.UUID, nil } func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) { if s.pricelistRepo == nil { return pricelistID, nil } if pricelistID != nil && *pricelistID > 0 { if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil { return nil, err } return pricelistID, nil } latest, err := s.pricelistRepo.GetLatestActive() if err != nil { return nil, nil } return &latest.ID, nil } // RefreshPricesNoAuth refreshes prices without ownership check func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } var latestPricelistID *uint if s.pricelistRepo != nil { if pl, err := s.pricelistRepo.GetLatestActive(); err == nil { latestPricelistID = &pl.ID } } updatedItems := make(models.ConfigItems, len(config.Items)) for i, item := range config.Items { if latestPricelistID != nil { if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 { updatedItems[i] = models.ConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: price, } continue } } if s.componentRepo == nil { updatedItems[i] = item continue } metadata, err := s.componentRepo.GetByLotName(item.LotName) if err != nil || metadata.CurrentPrice == nil { updatedItems[i] = item continue } updatedItems[i] = models.ConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: *metadata.CurrentPrice, } } config.Items = updatedItems total := updatedItems.Total() if config.ServerCount > 1 { total *= float64(config.ServerCount) } config.TotalPrice = &total if latestPricelistID != nil { config.PricelistID = latestPricelistID } now := time.Now() config.PriceUpdatedAt = &now if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) { if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage return s.configRepo.ListTemplates(offset, perPage) } // RefreshPrices updates all component prices in the configuration with current prices func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) { config, err := s.configRepo.GetByUUID(uuid) if err != nil { return nil, ErrConfigNotFound } if !s.isOwner(config, ownerUsername) { return nil, ErrConfigForbidden } var latestPricelistID *uint if s.pricelistRepo != nil { if pl, err := s.pricelistRepo.GetLatestActive(); err == nil { latestPricelistID = &pl.ID } } // Update prices for all items updatedItems := make(models.ConfigItems, len(config.Items)) for i, item := range config.Items { if latestPricelistID != nil { if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 { updatedItems[i] = models.ConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: price, } continue } } // Get current component price if s.componentRepo == nil { updatedItems[i] = item continue } metadata, err := s.componentRepo.GetByLotName(item.LotName) if err != nil || metadata.CurrentPrice == nil { // Keep original item if component not found or no price available updatedItems[i] = item continue } // Update item with current price updatedItems[i] = models.ConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: *metadata.CurrentPrice, } } // Update configuration config.Items = updatedItems total := updatedItems.Total() // If server count is greater than 1, multiply the total by server count if config.ServerCount > 1 { total *= float64(config.ServerCount) } config.TotalPrice = &total if latestPricelistID != nil { config.PricelistID = latestPricelistID } // Set price update timestamp now := time.Now() config.PriceUpdatedAt = &now if err := s.configRepo.Update(config); err != nil { return nil, err } return config, nil } func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool { if config == nil || ownerUsername == "" { return false } if config.OwnerUsername != "" { return config.OwnerUsername == ownerUsername } if config.User != nil { return config.User.Username == ownerUsername } return false } // // Export configuration as JSON // type ConfigExport struct { // Name string `json:"name"` // Notes string `json:"notes"` // Items models.ConfigItems `json:"items"` // } // // func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) { // config, err := s.GetByUUID(uuid, userID) // if err != nil { // return nil, err // } // // export := ConfigExport{ // Name: config.Name, // Notes: config.Notes, // Items: config.Items, // } // // return json.MarshalIndent(export, "", " ") // } // // func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) { // var export ConfigExport // if err := json.Unmarshal(data, &export); err != nil { // return nil, err // } // // req := &CreateConfigRequest{ // Name: export.Name, // Notes: export.Notes, // Items: export.Items, // } // // return s.Create(userID, req) // }