diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 55166ee..5eb5270 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) { Category: lc.Category, CategoryName: lc.Category, Model: lc.Model, - CurrentPrice: lc.CurrentPrice, } } @@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) { Category: component.Category, CategoryName: component.Category, Model: component.Model, - CurrentPrice: component.CurrentPrice, }) } diff --git a/internal/localdb/components.go b/internal/localdb/components.go index a5430f4..a199e4f 100644 --- a/internal/localdb/components.go +++ b/internal/localdb/components.go @@ -28,14 +28,13 @@ type ComponentSyncResult struct { func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { startTime := time.Now() - // Query to join lot with qt_lot_metadata + // Query to join lot with qt_lot_metadata (metadata only, no pricing) // Use LEFT JOIN to include lots without metadata type componentRow struct { LotName string LotDescription string Category *string Model *string - CurrentPrice *float64 } var rows []componentRow @@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) l.lot_name, l.lot_description, COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, - m.model, - m.current_price + m.model FROM lot l LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name LEFT JOIN qt_categories c ON m.category_id = c.id @@ -100,8 +98,6 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) LotDescription: row.LotDescription, Category: category, Model: model, - CurrentPrice: row.CurrentPrice, - SyncedAt: syncTime, } components = append(components, comp) @@ -221,11 +217,6 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L ) } - // Apply price filter - if filter.HasPrice { - db = db.Where("current_price IS NOT NULL") - } - // Get total count var total int64 if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { @@ -312,98 +303,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool { return time.Since(*syncTime).Hours() > float64(maxAgeHours) } -// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items -// This allows offline price updates using synced pricelists without MariaDB connection -func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) { - // Get all items from the specified pricelist - var items []LocalPricelistItem - if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil { - return 0, fmt.Errorf("fetching pricelist items: %w", err) - } - - if len(items) == 0 { - slog.Warn("no items found in pricelist", "pricelist_id", pricelistID) - return 0, nil - } - - // Update current_price for each component - updated := 0 - err := l.db.Transaction(func(tx *gorm.DB) error { - for _, item := range items { - result := tx.Model(&LocalComponent{}). - Where("lot_name = ?", item.LotName). - Update("current_price", item.Price) - - if result.Error != nil { - return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error) - } - - if result.RowsAffected > 0 { - updated++ - } - } - return nil - }) - - if err != nil { - return 0, err - } - - slog.Info("updated component prices from pricelist", - "pricelist_id", pricelistID, - "total_items", len(items), - "updated_components", updated) - - return updated, nil -} - -// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components -// if no components exist or all current prices are NULL -func (l *LocalDB) EnsureComponentPricesFromPricelists() error { - // Check if we have any components with prices - var count int64 - if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil { - return fmt.Errorf("checking component prices: %w", err) - } - - // If we have components with prices, don't load from pricelists - if count > 0 { - return nil - } - - // Check if we have any components at all - var totalComponents int64 - if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil { - return fmt.Errorf("counting components: %w", err) - } - - // If we have no components, we need to load them from pricelists - if totalComponents == 0 { - slog.Info("no components found in local database, loading from latest pricelist") - // This would typically be called from the sync service or setup process - // For now, we'll just return nil to indicate no action needed - return nil - } - - // If we have components but no prices, load from latest estimate pricelist. - var latestPricelist LocalPricelist - if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil { - if err == gorm.ErrRecordNotFound { - slog.Warn("no pricelists found in local database") - return nil - } - return fmt.Errorf("finding latest pricelist: %w", err) - } - - // Update prices from the latest pricelist - updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID) - if err != nil { - return fmt.Errorf("updating component prices from pricelist: %w", err) - } - - slog.Info("loaded component prices from latest pricelist", - "pricelist_id", latestPricelist.ID, - "updated_components", updated) - - return nil -} diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 2beb33d..e9b2e7a 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -213,17 +213,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent { LotDescription: lotDesc, Category: category, Model: meta.Model, - CurrentPrice: meta.CurrentPrice, - SyncedAt: time.Now(), } } // LocalToComponent converts LocalComponent to models.LotMetadata func LocalToComponent(local *LocalComponent) *models.LotMetadata { return &models.LotMetadata{ - LotName: local.LotName, - Model: local.Model, - CurrentPrice: local.CurrentPrice, + LotName: local.LotName, + Model: local.Model, Lot: &models.Lot{ LotName: local.LotName, LotDescription: local.LotDescription, diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index a7a6a53..01e3213 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -58,6 +58,16 @@ var localMigrations = []localMigration{ name: "Backfill source for local pricelists and create source indexes", run: backfillLocalPricelistSource, }, + { + id: "2026_02_09_drop_component_unused_fields", + name: "Remove current_price and synced_at from local_components (unused fields)", + run: dropComponentUnusedFields, + }, + { + id: "2026_02_09_add_warehouse_competitor_pricelists", + name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations", + run: addWarehouseCompetitorPriceLists, + }, } func runLocalMigrations(db *gorm.DB) error { @@ -316,3 +326,113 @@ func backfillLocalPricelistSource(tx *gorm.DB) error { return nil } + +func dropComponentUnusedFields(tx *gorm.DB) error { + // Check if columns exist + type columnInfo struct { + Name string `gorm:"column:name"` + } + + var columns []columnInfo + if err := tx.Raw(` + SELECT name FROM pragma_table_info('local_components') + WHERE name IN ('current_price', 'synced_at') + `).Scan(&columns).Error; err != nil { + return fmt.Errorf("check columns existence: %w", err) + } + + if len(columns) == 0 { + slog.Info("unused fields already removed from local_components") + return nil + } + + // SQLite: recreate table without current_price and synced_at + if err := tx.Exec(` + CREATE TABLE local_components_new ( + lot_name TEXT PRIMARY KEY, + lot_description TEXT, + category TEXT, + model TEXT + ) + `).Error; err != nil { + return fmt.Errorf("create new local_components table: %w", err) + } + + if err := tx.Exec(` + INSERT INTO local_components_new (lot_name, lot_description, category, model) + SELECT lot_name, lot_description, category, model + FROM local_components + `).Error; err != nil { + return fmt.Errorf("copy data to new table: %w", err) + } + + if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil { + return fmt.Errorf("drop old table: %w", err) + } + + if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil { + return fmt.Errorf("rename new table: %w", err) + } + + slog.Info("dropped current_price and synced_at columns from local_components") + return nil +} + +func addWarehouseCompetitorPriceLists(tx *gorm.DB) error { + // Check if columns exist + type columnInfo struct { + Name string `gorm:"column:name"` + } + + var columns []columnInfo + if err := tx.Raw(` + SELECT name FROM pragma_table_info('local_configurations') + WHERE name IN ('warehouse_pricelist_id', 'competitor_pricelist_id') + `).Scan(&columns).Error; err != nil { + return fmt.Errorf("check columns existence: %w", err) + } + + if len(columns) == 2 { + slog.Info("warehouse and competitor pricelist columns already exist") + return nil + } + + // Add columns if they don't exist + if err := tx.Exec(` + ALTER TABLE local_configurations + ADD COLUMN warehouse_pricelist_id INTEGER + `).Error; err != nil { + // Column might already exist, ignore + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("add warehouse_pricelist_id column: %w", err) + } + } + + if err := tx.Exec(` + ALTER TABLE local_configurations + ADD COLUMN competitor_pricelist_id INTEGER + `).Error; err != nil { + // Column might already exist, ignore + if !strings.Contains(err.Error(), "duplicate column") { + return fmt.Errorf("add competitor_pricelist_id column: %w", err) + } + } + + // Create indexes + if err := tx.Exec(` + CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist + ON local_configurations(warehouse_pricelist_id) + `).Error; err != nil { + return fmt.Errorf("create warehouse pricelist index: %w", err) + } + + if err := tx.Exec(` + CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist + ON local_configurations(competitor_pricelist_id) + `).Error; err != nil { + return fmt.Errorf("create competitor pricelist index: %w", err) + } + + slog.Info("added warehouse and competitor pricelist fields to local_configurations") + return nil +} diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 33f1daa..e7b5281 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -96,8 +96,10 @@ type LocalConfiguration struct { Notes string `json:"notes"` IsTemplate bool `gorm:"default:false" json:"is_template"` ServerCount int `gorm:"default:1" json:"server_count"` - PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` - OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` + PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` + WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` + CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` + OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -179,14 +181,13 @@ func (LocalPricelistItem) TableName() string { return "local_pricelist_items" } -// LocalComponent stores cached components for offline search +// LocalComponent stores cached components for offline search (metadata only) +// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection type LocalComponent struct { - LotName string `gorm:"primaryKey" json:"lot_name"` - LotDescription string `json:"lot_description"` - Category string `json:"category"` - Model string `json:"model"` - CurrentPrice *float64 `json:"current_price"` - SyncedAt time.Time `json:"synced_at"` + LotName string `gorm:"primaryKey" json:"lot_name"` + LotDescription string `json:"lot_description"` + Category string `json:"category"` + Model string `json:"model"` } func (LocalComponent) TableName() string { diff --git a/internal/repository/unified.go b/internal/repository/unified.go index 97583e6..9e803b2 100644 --- a/internal/repository/unified.go +++ b/internal/repository/unified.go @@ -83,10 +83,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit search := "%" + filter.Search + "%" query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search) } - if filter.HasPrice { - query = query.Where("current_price IS NOT NULL AND current_price > 0") - } - var total int64 query.Count(&total) @@ -96,8 +92,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit sortDir = "DESC" } switch filter.SortField { - case "current_price": - query = query.Order("current_price " + sortDir) case "lot_name": query = query.Order("lot_name " + sortDir) default: @@ -112,9 +106,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit result := make([]models.LotMetadata, len(components)) for i, comp := range components { result[i] = models.LotMetadata{ - LotName: comp.LotName, - Model: comp.Model, - CurrentPrice: comp.CurrentPrice, + LotName: comp.LotName, + Model: comp.Model, Lot: &models.Lot{ LotName: comp.LotName, LotDescription: comp.LotDescription, @@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) } return &models.LotMetadata{ - LotName: comp.LotName, - Model: comp.Model, - CurrentPrice: comp.CurrentPrice, + LotName: comp.LotName, + Model: comp.Model, Lot: &models.Lot{ LotName: comp.LotName, LotDescription: comp.LotDescription, diff --git a/internal/services/component.go b/internal/services/component.go index 2208a08..5494a90 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -53,7 +53,6 @@ type ComponentView struct { Category string `json:"category"` CategoryName string `json:"category_name"` Model string `json:"model"` - CurrentPrice *float64 `json:"current_price"` PriceFreshness models.PriceFreshness `json:"price_freshness"` PopularityScore float64 `json:"popularity_score"` Specs models.Specs `json:"specs,omitempty"` @@ -92,7 +91,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage view := ComponentView{ LotName: c.LotName, Model: c.Model, - CurrentPrice: c.CurrentPrice, PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3), PopularityScore: c.PopularityScore, Specs: c.Specs, @@ -134,7 +132,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) view := &ComponentView{ LotName: c.LotName, Model: c.Model, - CurrentPrice: c.CurrentPrice, PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3), PopularityScore: c.PopularityScore, Specs: c.Specs, diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 84ad2db..24d3a98 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -347,7 +347,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str } latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() - // Update prices for all items + // Update prices for all items from pricelist updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) for i, item := range localCfg.Items { if latestErr == nil && latestPricelist != nil { @@ -362,20 +362,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str } } - // Fallback to current component price from local cache - component, err := s.localDB.GetLocalComponent(item.LotName) - if err != nil || component.CurrentPrice == nil { - // Keep original item if component not found or no price available - updatedItems[i] = item - continue - } - - // Update item with current price from local cache - updatedItems[i] = localdb.LocalConfigItem{ - LotName: item.LotName, - Quantity: item.Quantity, - UnitPrice: *component.CurrentPrice, - } + // Keep original item if price not found in pricelist + updatedItems[i] = item } // Update configuration @@ -672,7 +660,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co } latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() - // Update prices for all items + // Update prices for all items from pricelist updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) for i, item := range localCfg.Items { if latestErr == nil && latestPricelist != nil { @@ -687,20 +675,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co } } - // Fallback to current component price from local cache - component, err := s.localDB.GetLocalComponent(item.LotName) - if err != nil || component.CurrentPrice == nil { - // Keep original item if component not found or no price available - updatedItems[i] = item - continue - } - - // Update item with current price from local cache - updatedItems[i] = localdb.LocalConfigItem{ - LotName: item.LotName, - Quantity: item.Quantity, - UnitPrice: *component.CurrentPrice, - } + // Keep original item if price not found in pricelist + updatedItems[i] = item } // Update configuration diff --git a/internal/services/quote.go b/internal/services/quote.go index d38e7ed..8185a3d 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -78,6 +78,7 @@ type QuoteRequest struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` } `json:"items"` + PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing } type PriceLevelsRequest struct { @@ -123,6 +124,16 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation Warnings: make([]string, 0), } + // Determine which pricelist to use for pricing + pricelistID := req.PricelistID + if pricelistID == nil || *pricelistID == 0 { + // By default, use latest estimate pricelist + latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate") + if err == nil && latestPricelist != nil { + pricelistID = &latestPricelist.ServerID + } + } + var total float64 for _, reqItem := range req.Items { localComp, err := s.localDB.GetLocalComponent(reqItem.LotName) @@ -142,13 +153,19 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation TotalPrice: 0, } - if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 { - item.UnitPrice = *localComp.CurrentPrice - item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity) - item.HasPrice = true - total += item.TotalPrice + // Get price from pricelist_items + if pricelistID != nil { + price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName) + if found && 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) + } } else { - result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName) + result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName) } result.Items = append(result.Items, item) diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 17fda74..b82f9c3 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -346,17 +346,10 @@ func (s *Service) SyncPricelists() (int, error) { } synced := 0 - var latestEstimateLocalID uint - var latestEstimateCreatedAt time.Time for _, pl := range serverPricelists { // Check if pricelist already exists locally existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) if existing != nil { - // Track latest estimate pricelist by created_at for component refresh. - if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) { - latestEstimateCreatedAt = pl.CreatedAt - latestEstimateLocalID = existing.ID - } continue } @@ -385,10 +378,6 @@ func (s *Service) SyncPricelists() (int, error) { slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) } - if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) { - latestEstimateCreatedAt = pl.CreatedAt - latestEstimateLocalID = localPL.ID - } synced++ } @@ -399,16 +388,6 @@ func (s *Service) SyncPricelists() (int, error) { slog.Info("deleted stale local pricelists", "deleted", removed) } - // Update component prices from latest estimate pricelist only. - if latestEstimateLocalID > 0 { - updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID) - if err != nil { - slog.Warn("failed to update component prices from pricelist", "error", err) - } else { - slog.Info("updated component prices from latest pricelist", "updated", updated) - } - } - // Update last sync time s.localDB.SetLastSyncTime(time.Now()) s.RecordSyncHeartbeat() diff --git a/qfs b/qfs index ab6076d..df3db2c 100755 Binary files a/qfs and b/qfs differ