diff --git a/bible-local/03-database.md b/bible-local/03-database.md index e082b37..ae1bb6a 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -25,6 +25,7 @@ Rules: - cache tables may be rebuilt if local migration recovery requires it; - user-authored tables must not be dropped as a recovery shortcut; - `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table; +- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`; - configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows; - `local_components` table has been removed; any reference to it is dead code. @@ -35,8 +36,6 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15. ### QuoteForge tables (qt_*) Runtime read: -- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them) -- `qt_lot_metadata` — component metadata, price settings - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelist_items` — pricelist rows - `qt_partnumber_books` — partnumber book headers @@ -53,6 +52,8 @@ Insert-only tracking: - `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI Server-side only (not queried by client runtime): +- `qt_categories` — pricelist category registry; QF runtime serves category lists for the UI from `models.DefaultCategories` (Go) overlaid with categories present in `local_pricelist_items`, not from this table. `name`/`name_ru` columns being removed. +- `qt_lot_metadata` — component metadata / price settings; the Go server-side component/category management layer (`ComponentRepository`, `CategoryRepository`, `ComponentService`) was removed — no client code reads this table - `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs) - `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime) - `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index e9f2aa7..06e81a9 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -677,8 +677,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect var projectService *services.ProjectService syncService = sync.NewService(connMgr, local) - componentService := services.NewComponentService(nil, nil) - quoteService := services.NewQuoteService(nil, nil, local, nil) + quoteService := services.NewQuoteService(nil, local) exportService := services.NewExportService(cfg.Export, local) // isOnline function for local-first architecture @@ -775,7 +774,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect templatesPath := filepath.Join("web", "templates") // Handlers - componentHandler := handlers.NewComponentHandler(componentService, local) + componentHandler := handlers.NewComponentHandler(local) quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) pricelistHandler := handlers.NewPricelistHandler(local) diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 0a5fa42..d24a79f 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -7,20 +7,17 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" "github.com/gin-gonic/gin" ) type ComponentHandler struct { - componentService *services.ComponentService - localDB *localdb.LocalDB + localDB *localdb.LocalDB } -func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler { +func NewComponentHandler(localDB *localdb.LocalDB) *ComponentHandler { return &ComponentHandler{ - componentService: componentService, - localDB: localDB, + localDB: localDB, } } @@ -34,17 +31,10 @@ func (h *ComponentHandler) List(c *gin.Context) { perPage = 20 } - filter := repository.ComponentFilter{ - Category: c.Query("category"), - Search: c.Query("search"), - HasPrice: c.Query("has_price") == "true", - ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются - } - localFilter := localdb.ComponentFilter{ - Category: filter.Category, - Search: filter.Search, - HasPrice: filter.HasPrice, + Category: c.Query("category"), + Search: c.Query("search"), + HasPrice: c.Query("has_price") == "true", } offset := (page - 1) * perPage localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 8a6c355..de0347a 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -294,33 +294,6 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo } } -// ComponentToLocal converts models.LotMetadata to LocalComponent -func ComponentToLocal(meta *models.LotMetadata) *LocalComponent { - var lotDesc string - var category string - - if meta.Lot != nil { - lotDesc = meta.Lot.LotDescription - } - - // Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") - if len(meta.LotName) > 0 { - for i, ch := range meta.LotName { - if ch == '_' { - category = meta.LotName[:i] - break - } - } - } - - return &LocalComponent{ - LotName: meta.LotName, - LotDescription: lotDesc, - Category: category, - Model: meta.Model, - } -} - // LocalToComponent converts LocalComponent to models.LotMetadata func LocalToComponent(local *LocalComponent) *models.LotMetadata { return &models.LotMetadata{ diff --git a/internal/repository/category.go b/internal/repository/category.go deleted file mode 100644 index 3aea062..0000000 --- a/internal/repository/category.go +++ /dev/null @@ -1,76 +0,0 @@ -package repository - -import ( - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type CategoryRepository struct { - db *gorm.DB -} - -func NewCategoryRepository(db *gorm.DB) *CategoryRepository { - return &CategoryRepository{db: db} -} - -func (r *CategoryRepository) GetAll() ([]models.Category, error) { - var categories []models.Category - err := r.db.Order("display_order ASC").Find(&categories).Error - return categories, err -} - -func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) { - var category models.Category - err := r.db.Where("code = ?", code).First(&category).Error - if err != nil { - return nil, err - } - return &category, nil -} - -func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) { - var category models.Category - err := r.db.First(&category, id).Error - if err != nil { - return nil, err - } - return &category, nil -} - -// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does -func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) { - // Try to find existing - existing, err := r.GetByCode(code) - if err == nil { - return existing, nil - } - - // Get max display order to put new category at the end - var maxOrder int - r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder) - - // Create new category - newCat := &models.Category{ - Code: code, - Name: code, // Use code as name initially - NameRu: code, - DisplayOrder: maxOrder + 1, - IsRequired: false, - } - - if err := r.db.Create(newCat).Error; err != nil { - return nil, err - } - - return newCat, nil -} - -// Create creates a new category -func (r *CategoryRepository) Create(category *models.Category) error { - return r.db.Create(category).Error -} - -// Update updates an existing category -func (r *CategoryRepository) Update(category *models.Category) error { - return r.db.Save(category).Error -} diff --git a/internal/repository/component.go b/internal/repository/component.go deleted file mode 100644 index 3ca3fb8..0000000 --- a/internal/repository/component.go +++ /dev/null @@ -1,140 +0,0 @@ -package repository - -import ( - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type ComponentRepository struct { - db *gorm.DB -} - -func NewComponentRepository(db *gorm.DB) *ComponentRepository { - return &ComponentRepository{db: db} -} - -type ComponentFilter struct { - Category string - Search string - HasPrice bool - ExcludeHidden bool - SortField string - SortDir string -} - -func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { - var components []models.LotMetadata - var total int64 - - query := r.db.Model(&models.LotMetadata{}). - Preload("Lot"). - Preload("Category") - - if filter.Category != "" { - query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id"). - Where("qt_categories.code = ?", filter.Category) - } - if filter.Search != "" { - search := "%" + filter.Search + "%" - query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search) - } - if filter.HasPrice { - query = query.Where("current_price IS NOT NULL AND current_price > 0") - } - if filter.ExcludeHidden { - query = query.Where("is_hidden = ? OR is_hidden IS NULL", false) - } - - query.Count(&total) - - // Apply sorting - sortDir := "ASC" - if filter.SortDir == "desc" { - sortDir = "DESC" - } - - switch filter.SortField { - case "popularity_score": - query = query.Order("popularity_score " + sortDir) - case "current_price": - query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END"). - Order("current_price " + sortDir) - case "lot_name": - query = query.Order("lot_name " + sortDir) - default: - // Default: sort by popularity, no price goes last - query = query. - Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END"). - Order("popularity_score DESC") - } - - err := query. - Offset(offset). - Limit(limit). - Find(&components).Error - - return components, total, err -} - -func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) { - var component models.LotMetadata - err := r.db. - Preload("Lot"). - Preload("Category"). - Where("lot_name = ?", lotName). - First(&component).Error - if err != nil { - return nil, err - } - return &component, nil -} - -func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) { - var components []models.LotMetadata - err := r.db. - Preload("Lot"). - Preload("Category"). - Where("lot_name IN ?", lotNames). - Find(&components).Error - return components, err -} - -func (r *ComponentRepository) Update(component *models.LotMetadata) error { - return r.db.Save(component).Error -} - -func (r *ComponentRepository) DB() *gorm.DB { - return r.db -} - -func (r *ComponentRepository) Create(component *models.LotMetadata) error { - return r.db.Create(component).Error -} - -func (r *ComponentRepository) IncrementRequestCount(lotName string) error { - now := time.Now() - return r.db.Model(&models.LotMetadata{}). - Where("lot_name = ?", lotName). - Updates(map[string]interface{}{ - "request_count": gorm.Expr("request_count + 1"), - "last_request_date": now, - }).Error -} - -// GetAllLots returns all lots from the existing lot table -func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) { - var lots []models.Lot - err := r.db.Find(&lots).Error - return lots, err -} - -// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries -func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) { - var lots []models.Lot - err := r.db. - Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)"). - Find(&lots).Error - return lots, err -} diff --git a/internal/repository/unified.go b/internal/repository/unified.go deleted file mode 100644 index 9e803b2..0000000 --- a/internal/repository/unified.go +++ /dev/null @@ -1,393 +0,0 @@ -package repository - -import ( - "encoding/json" - "fmt" - "time" - - "git.mchus.pro/mchus/quoteforge/internal/localdb" - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -// DataSource defines the unified interface for data access -// It abstracts whether data comes from MariaDB (online) or SQLite (offline) -type DataSource interface { - // Components - GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) - GetComponent(lotName string) (*models.LotMetadata, error) - - // Configurations - SaveConfiguration(cfg *models.Configuration) error - GetConfigurations(ownerUsername string) ([]models.Configuration, error) - GetConfigurationByUUID(uuid string) (*models.Configuration, error) - DeleteConfiguration(uuid string) error - - // Pricelists (read-only in offline mode) - GetPricelists() ([]models.PricelistSummary, error) - GetPricelistByID(id uint) (*models.Pricelist, error) - GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) - GetLatestPricelist() (*models.Pricelist, error) -} - -// UnifiedRepo implements DataSource with automatic online/offline switching -type UnifiedRepo struct { - mariaDB *gorm.DB - localDB *localdb.LocalDB - isOnline bool -} - -// NewUnifiedRepo creates a new unified repository -func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo { - return &UnifiedRepo{ - mariaDB: mariaDB, - localDB: localDB, - isOnline: isOnline, - } -} - -// SetOnlineStatus updates the online/offline status -func (r *UnifiedRepo) SetOnlineStatus(online bool) { - r.isOnline = online -} - -// IsOnline returns the current online/offline status -func (r *UnifiedRepo) IsOnline() bool { - return r.isOnline -} - -// Component methods - -// GetComponents returns components from MariaDB (online) or local cache (offline) -func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { - if r.isOnline { - return r.getComponentsOnline(filter, offset, limit) - } - return r.getComponentsOffline(filter, offset, limit) -} - -func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { - repo := NewComponentRepository(r.mariaDB) - return repo.List(filter, offset, limit) -} - -func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { - var components []localdb.LocalComponent - query := r.localDB.DB().Model(&localdb.LocalComponent{}) - - // Apply filters - if filter.Category != "" { - query = query.Where("category = ?", filter.Category) - } - if filter.Search != "" { - search := "%" + filter.Search + "%" - query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search) - } - var total int64 - query.Count(&total) - - // Apply sorting - sortDir := "ASC" - if filter.SortDir == "desc" { - sortDir = "DESC" - } - switch filter.SortField { - case "lot_name": - query = query.Order("lot_name " + sortDir) - default: - query = query.Order("lot_name ASC") - } - - if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil { - return nil, 0, fmt.Errorf("fetching offline components: %w", err) - } - - // Convert to models.LotMetadata - result := make([]models.LotMetadata, len(components)) - for i, comp := range components { - result[i] = models.LotMetadata{ - LotName: comp.LotName, - Model: comp.Model, - Lot: &models.Lot{ - LotName: comp.LotName, - LotDescription: comp.LotDescription, - }, - } - } - - return result, total, nil -} - -// GetComponent returns a single component by lot name -func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) { - if r.isOnline { - repo := NewComponentRepository(r.mariaDB) - return repo.GetByLotName(lotName) - } - - var comp localdb.LocalComponent - if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil { - return nil, fmt.Errorf("fetching offline component: %w", err) - } - - return &models.LotMetadata{ - LotName: comp.LotName, - Model: comp.Model, - Lot: &models.Lot{ - LotName: comp.LotName, - LotDescription: comp.LotDescription, - }, - }, nil -} - -// Configuration methods - -// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes) -func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error { - if r.isOnline { - repo := NewConfigurationRepository(r.mariaDB) - return repo.Create(cfg) - } - - // Offline: save to local SQLite and queue for sync - localCfg := &localdb.LocalConfiguration{ - UUID: cfg.UUID, - Name: cfg.Name, - TotalPrice: cfg.TotalPrice, - CustomPrice: cfg.CustomPrice, - Notes: cfg.Notes, - IsTemplate: cfg.IsTemplate, - ServerCount: cfg.ServerCount, - CreatedAt: cfg.CreatedAt, - UpdatedAt: time.Now(), - SyncStatus: "pending", - OriginalUsername: cfg.OwnerUsername, - } - - // Convert items - localItems := make(localdb.LocalConfigItems, len(cfg.Items)) - for i, item := range cfg.Items { - localItems[i] = localdb.LocalConfigItem{ - LotName: item.LotName, - Quantity: item.Quantity, - UnitPrice: item.UnitPrice, - } - } - localCfg.Items = localItems - - if err := r.localDB.SaveConfiguration(localCfg); err != nil { - return fmt.Errorf("saving local configuration: %w", err) - } - - // Add to pending changes queue - payload, err := json.Marshal(cfg) - if err != nil { - return fmt.Errorf("marshaling configuration for sync: %w", err) - } - - return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)) -} - -// GetConfigurations returns all configurations for a user -func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) { - if r.isOnline { - repo := NewConfigurationRepository(r.mariaDB) - configs, _, err := repo.ListByUser(ownerUsername, 0, 1000) - return configs, err - } - - // Offline: get from local SQLite - localConfigs, err := r.localDB.GetConfigurations() - if err != nil { - return nil, fmt.Errorf("fetching local configurations: %w", err) - } - - // Convert to models.Configuration - result := make([]models.Configuration, len(localConfigs)) - for i, lc := range localConfigs { - items := make(models.ConfigItems, len(lc.Items)) - for j, item := range lc.Items { - items[j] = models.ConfigItem{ - LotName: item.LotName, - Quantity: item.Quantity, - UnitPrice: item.UnitPrice, - } - } - - result[i] = models.Configuration{ - UUID: lc.UUID, - OwnerUsername: lc.OriginalUsername, - Name: lc.Name, - Items: items, - TotalPrice: lc.TotalPrice, - CustomPrice: lc.CustomPrice, - Notes: lc.Notes, - IsTemplate: lc.IsTemplate, - ServerCount: lc.ServerCount, - CreatedAt: lc.CreatedAt, - } - } - - return result, nil -} - -// GetConfigurationByUUID returns a configuration by UUID -func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) { - if r.isOnline { - repo := NewConfigurationRepository(r.mariaDB) - return repo.GetByUUID(uuid) - } - - localCfg, err := r.localDB.GetConfigurationByUUID(uuid) - if err != nil { - return nil, fmt.Errorf("fetching local configuration: %w", err) - } - - items := make(models.ConfigItems, len(localCfg.Items)) - for i, item := range localCfg.Items { - items[i] = models.ConfigItem{ - LotName: item.LotName, - Quantity: item.Quantity, - UnitPrice: item.UnitPrice, - } - } - - return &models.Configuration{ - UUID: localCfg.UUID, - Name: localCfg.Name, - Items: items, - TotalPrice: localCfg.TotalPrice, - CustomPrice: localCfg.CustomPrice, - Notes: localCfg.Notes, - IsTemplate: localCfg.IsTemplate, - ServerCount: localCfg.ServerCount, - CreatedAt: localCfg.CreatedAt, - }, nil -} - -// DeleteConfiguration deletes a configuration -func (r *UnifiedRepo) DeleteConfiguration(uuid string) error { - if r.isOnline { - // Get ID first - cfg, err := r.GetConfigurationByUUID(uuid) - if err != nil { - return err - } - repo := NewConfigurationRepository(r.mariaDB) - return repo.Delete(cfg.ID) - } - - // Offline: delete from local and queue sync - if err := r.localDB.DeleteConfiguration(uuid); err != nil { - return fmt.Errorf("deleting local configuration: %w", err) - } - - return r.localDB.AddPendingChange("configuration", uuid, "delete", "") -} - -// Pricelist methods - -// GetPricelists returns all pricelists -func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) { - if r.isOnline { - repo := NewPricelistRepository(r.mariaDB) - summaries, _, err := repo.List(0, 1000) - return summaries, err - } - - // Offline: get from local cache - localPLs, err := r.localDB.GetLocalPricelists() - if err != nil { - return nil, fmt.Errorf("fetching local pricelists: %w", err) - } - - summaries := make([]models.PricelistSummary, len(localPLs)) - for i, pl := range localPLs { - itemCount := r.localDB.CountLocalPricelistItems(pl.ID) - summaries[i] = models.PricelistSummary{ - ID: pl.ServerID, - Version: pl.Version, - CreatedAt: pl.CreatedAt, - ItemCount: itemCount, - } - } - - return summaries, nil -} - -// GetPricelistByID returns a pricelist by ID -func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) { - if r.isOnline { - repo := NewPricelistRepository(r.mariaDB) - return repo.GetByID(id) - } - - // Offline: get from local cache - localPL, err := r.localDB.GetLocalPricelistByServerID(id) - if err != nil { - return nil, fmt.Errorf("fetching local pricelist: %w", err) - } - - itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) - return &models.Pricelist{ - ID: localPL.ServerID, - Version: localPL.Version, - CreatedAt: localPL.CreatedAt, - ItemCount: int(itemCount), - }, nil -} - -// GetPricelistItems returns items for a pricelist -func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) { - if r.isOnline { - repo := NewPricelistRepository(r.mariaDB) - items, _, err := repo.GetItems(pricelistID, 0, 100000, "") - return items, err - } - - // Offline: get from local cache - // First find the local pricelist by server ID - localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID) - if err != nil { - return nil, fmt.Errorf("fetching local pricelist: %w", err) - } - - localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID) - if err != nil { - return nil, fmt.Errorf("fetching local pricelist items: %w", err) - } - - items := make([]models.PricelistItem, len(localItems)) - for i, item := range localItems { - items[i] = models.PricelistItem{ - ID: item.ID, - PricelistID: pricelistID, - LotName: item.LotName, - Price: item.Price, - } - } - - return items, nil -} - -// GetLatestPricelist returns the latest pricelist -func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) { - if r.isOnline { - repo := NewPricelistRepository(r.mariaDB) - return repo.GetLatestActive() - } - - // Offline: get from local cache - localPL, err := r.localDB.GetLatestLocalPricelist() - if err != nil { - return nil, fmt.Errorf("fetching latest local pricelist: %w", err) - } - - itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) - return &models.Pricelist{ - ID: localPL.ServerID, - Version: localPL.Version, - CreatedAt: localPL.CreatedAt, - ItemCount: int(itemCount), - }, nil -} diff --git a/internal/services/component.go b/internal/services/component.go index ec1e32c..b4fd02f 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -1,43 +1,9 @@ package services import ( - "fmt" - "log/slog" - "strings" - "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" ) -type ComponentService struct { - componentRepo *repository.ComponentRepository - categoryRepo *repository.CategoryRepository -} - -func NewComponentService( - componentRepo *repository.ComponentRepository, - categoryRepo *repository.CategoryRepository, -) *ComponentService { - return &ComponentService{ - componentRepo: componentRepo, - categoryRepo: categoryRepo, - } -} - -// ParsePartNumber extracts category and model from lot_name -// "CPU_AMD_9654" → category="CPU", model="AMD_9654" -// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5" -func ParsePartNumber(lotName string) (category, model string) { - parts := strings.SplitN(lotName, "_", 2) - if len(parts) >= 1 { - category = parts[0] - } - if len(parts) >= 2 { - model = parts[1] - } - return -} - type ComponentListResult struct { Items []ComponentView `json:"items"` TotalCount int64 `json:"total_count"` @@ -56,169 +22,3 @@ type ComponentView struct { PopularityScore float64 `json:"popularity_score"` Specs models.Specs `json:"specs,omitempty"` } - -func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) { - // If no database connection (offline mode), return empty list - // Components should be loaded via /api/sync/components first - if s.componentRepo == nil { - return &ComponentListResult{ - Items: []ComponentView{}, - TotalCount: 0, - Page: page, - PerPage: perPage, - TotalPages: 1, - }, nil - } - - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 20 - } - if perPage > 5000 { - perPage = 5000 - } - offset := (page - 1) * perPage - - components, total, err := s.componentRepo.List(filter, offset, perPage) - if err != nil { - return nil, err - } - - views := make([]ComponentView, len(components)) - for i, c := range components { - view := ComponentView{ - LotName: c.LotName, - Model: c.Model, - PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3), - PopularityScore: c.PopularityScore, - Specs: c.Specs, - } - - if c.Lot != nil { - view.Description = c.Lot.LotDescription - } - if c.Category != nil { - view.Category = c.Category.Code - view.CategoryName = c.Category.Name - } - - views[i] = view - } - - totalPages := int((total + int64(perPage) - 1) / int64(perPage)) - if totalPages < 1 { - totalPages = 1 - } - return &ComponentListResult{ - Items: views, - TotalCount: total, - Page: page, - PerPage: perPage, - TotalPages: totalPages, - }, nil -} - -func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) { - // If no database connection (offline mode), return error - if s.componentRepo == nil { - return nil, fmt.Errorf("offline mode: component data not available") - } - - c, err := s.componentRepo.GetByLotName(lotName) - if err != nil { - return nil, err - } - - // Track usage (best-effort) - if err := s.componentRepo.IncrementRequestCount(lotName); err != nil { - slog.Warn("component: could not increment request count", "lot", lotName, "err", err) - } - - view := &ComponentView{ - LotName: c.LotName, - Model: c.Model, - PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3), - PopularityScore: c.PopularityScore, - Specs: c.Specs, - } - - if c.Lot != nil { - view.Description = c.Lot.LotDescription - } - if c.Category != nil { - view.Category = c.Category.Code - view.CategoryName = c.Category.Name - } - - return view, nil -} - -func (s *ComponentService) GetCategories() ([]models.Category, error) { - // If no database connection (offline mode), return default categories - if s.categoryRepo == nil { - return models.DefaultCategories, nil - } - return s.categoryRepo.GetAll() -} - -// ImportFromLot creates metadata entries for lots that don't have them -func (s *ComponentService) ImportFromLot() (int, error) { - // If no database connection (offline mode), return error - if s.componentRepo == nil || s.categoryRepo == nil { - return 0, fmt.Errorf("offline mode: import not available") - } - - lots, err := s.componentRepo.GetLotsWithoutMetadata() - if err != nil { - return 0, err - } - - categories, err := s.categoryRepo.GetAll() - if err != nil { - return 0, err - } - - categoryMap := make(map[string]uint) - for _, cat := range categories { - categoryMap[strings.ToUpper(cat.Code)] = cat.ID - } - - imported := 0 - for _, lot := range lots { - // Use lot_category from database if available, otherwise parse from lot_name - var category string - if lot.LotCategory != nil && *lot.LotCategory != "" { - category = strings.ToUpper(*lot.LotCategory) - } else { - category, _ = ParsePartNumber(lot.LotName) - category = strings.ToUpper(category) - } - - _, model := ParsePartNumber(lot.LotName) - - metadata := &models.LotMetadata{ - LotName: lot.LotName, - Model: model, - Specs: make(models.Specs), - } - - if catID, ok := categoryMap[category]; ok { - metadata.CategoryID = &catID - } else { - // Create new category if it doesn't exist - newCat, err := s.categoryRepo.CreateIfNotExists(category) - if err == nil && newCat != nil { - metadata.CategoryID = &newCat.ID - } - } - - if err := s.componentRepo.Create(metadata); err != nil { - continue - } - imported++ - } - - return imported, nil -} diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 9e24e43..7cf8ecd 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -2,11 +2,8 @@ package services import ( "errors" - "time" "git.mchus.pro/mchus/quoteforge/internal/models" - "git.mchus.pro/mchus/quoteforge/internal/repository" - "github.com/google/uuid" ) var ( @@ -14,37 +11,13 @@ var ( 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 +// ConfigurationGetter is an interface for services that can retrieve configurations. +// Used by handlers to work with LocalConfigurationService. type ConfigurationGetter interface { GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) GetByUUIDNoAuth(uuid 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"` @@ -70,583 +43,3 @@ type ArticlePreviewRequest struct { 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, - WarehousePricelistID: req.WarehousePricelistID, - CompetitorPricelistID: req.CompetitorPricelistID, - ConfigType: req.ConfigType, - DisablePriceRefresh: req.DisablePriceRefresh, - OnlyInStock: req.OnlyInStock, - } - if config.ConfigType == "" { - config.ConfigType = "server" - } - - if err := s.configRepo.Create(config); err != nil { - return nil, err - } - - 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.WarehousePricelistID = req.WarehousePricelistID - config.CompetitorPricelistID = req.CompetitorPricelistID - config.DisablePriceRefresh = req.DisablePriceRefresh - 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, - ServerModel: original.ServerModel, - SupportCode: original.SupportCode, - Article: original.Article, - PricelistID: original.PricelistID, - WarehousePricelistID: original.WarehousePricelistID, - CompetitorPricelistID: original.CompetitorPricelistID, - DisablePriceRefresh: original.DisablePriceRefresh, - 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.ServerModel = req.ServerModel - config.SupportCode = req.SupportCode - config.Article = req.Article - config.PricelistID = pricelistID - config.WarehousePricelistID = req.WarehousePricelistID - config.CompetitorPricelistID = req.CompetitorPricelistID - config.DisablePriceRefresh = req.DisablePriceRefresh - 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 - } - return config.OwnerUsername == ownerUsername -} - -// // 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) -// } diff --git a/internal/services/quote.go b/internal/services/quote.go index b8bd373..7610f58 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -18,32 +18,22 @@ var ( ) type QuoteService struct { - componentRepo *repository.ComponentRepository - pricelistRepo *repository.PricelistRepository - localDB *localdb.LocalDB - pricingService priceResolver - cacheMu sync.RWMutex - priceCache map[string]cachedLotPrice - cacheTTL time.Duration -} - -type priceResolver interface { - GetEffectivePrice(lotName string) (*float64, error) + pricelistRepo *repository.PricelistRepository + localDB *localdb.LocalDB + cacheMu sync.RWMutex + priceCache map[string]cachedLotPrice + cacheTTL time.Duration } func NewQuoteService( - componentRepo *repository.ComponentRepository, pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB, - pricingService priceResolver, ) *QuoteService { return &QuoteService{ - componentRepo: componentRepo, - pricelistRepo: pricelistRepo, - localDB: localDB, - pricingService: pricingService, - priceCache: make(map[string]cachedLotPrice, 4096), - cacheTTL: 10 * time.Second, + pricelistRepo: pricelistRepo, + localDB: localDB, + priceCache: make(map[string]cachedLotPrice, 4096), + cacheTTL: 10 * time.Second, } } @@ -175,73 +165,7 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation return result, nil } - if s.componentRepo == nil || s.pricingService == nil { - return nil, errors.New("quote calculation not available") - } - - result := &QuoteValidationResult{ - Valid: true, - Items: make([]QuoteItem, 0, len(req.Items)), - Errors: make([]string, 0), - Warnings: make([]string, 0), - } - - lotNames := make([]string, len(req.Items)) - quantities := make(map[string]int) - for i, item := range req.Items { - lotNames[i] = item.LotName - quantities[item.LotName] = item.Quantity - } - - components, err := s.componentRepo.GetMultiple(lotNames) - if err != nil { - return nil, err - } - - componentMap := make(map[string]*models.LotMetadata) - for i := range components { - componentMap[components[i].LotName] = &components[i] - } - - var total float64 - - for _, reqItem := range req.Items { - comp, exists := componentMap[reqItem.LotName] - if !exists { - result.Valid = false - result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName) - continue - } - - item := QuoteItem{ - LotName: reqItem.LotName, - Quantity: reqItem.Quantity, - HasPrice: false, - } - - if comp.Lot != nil { - item.Description = comp.Lot.LotDescription - } - if comp.Category != nil { - item.Category = comp.Category.Code - } - - // Get effective price (override or calculated) - price, err := s.pricingService.GetEffectivePrice(reqItem.LotName) - if err == nil && price != nil && *price > 0 { - item.UnitPrice = *price - item.TotalPrice = *price * float64(reqItem.Quantity) - item.HasPrice = true - total += item.TotalPrice - } else { - result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName) - } - - result.Items = append(result.Items, item) - } - - result.Total = total - return result, nil + return nil, errors.New("quote calculation requires local database") } func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) { diff --git a/internal/services/quote_price_levels_test.go b/internal/services/quote_price_levels_test.go index 1b9de91..bf5abe0 100644 --- a/internal/services/quote_price_levels_test.go +++ b/internal/services/quote_price_levels_test.go @@ -13,7 +13,7 @@ import ( func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { db := newPriceLevelsTestDB(t) repo := repository.NewPricelistRepository(db) - service := NewQuoteService(nil, repo, nil, nil) + service := NewQuoteService(repo, nil) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) _ = estimate @@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { db := newPriceLevelsTestDB(t) repo := repository.NewPricelistRepository(db) - service := NewQuoteService(nil, repo, nil, nil) + service := NewQuoteService(repo, nil) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)