package localdb import ( "fmt" "strings" "time" "gorm.io/gorm" ) // ComponentFilter for searching with filters type ComponentFilter struct { Category string Search string HasPrice bool } // ComponentSyncResult contains statistics from component sync type ComponentSyncResult struct { TotalSynced int NewCount int UpdateCount int Duration time.Duration } // latestActivePricelistID returns the local DB id of the most recently created // active pricelist for the given source ("estimate", "warehouse", etc.). func (l *LocalDB) latestActivePricelistID(source string) (uint, error) { var id uint err := l.db.Table("local_pricelists"). Select("id"). Where("is_active = ? AND source = ?", true, source). Order("created_at DESC, id DESC"). Limit(1). Scan(&id).Error if err != nil { return 0, err } if id == 0 { return 0, fmt.Errorf("no active %s pricelist", source) } return id, nil } // componentUniverse returns a query over the set of LOTs the configurator is allowed // to choose from. That set is the latest active `world` pricelist — the widest list we // have — plus, as a safety net, the latest active `estimate` pricelist, deduplicated by // UPPER(lot_name). // // Scoping this to `estimate` alone used to silently drop BOM lot_mappings: the resolver // matches a partnumber against the book, which knows nothing about pricelists, and the // frontend then rejected any LOT the estimate pricelist did not carry. Prices for // world-only LOTs already come from the world fallback (see // decisions/2026-07-10-world-pricelist-fallback.md). // // The estimate row wins a collision, so its category/description/price_quality stay // authoritative. A world-only row reports price_quality 0 so it renders at the red end of // the quality scale — the one place QF sets this field rather than only reading it. // Either pricelist may be absent; only both missing is an error. func (l *LocalDB) componentUniverse() (*gorm.DB, error) { estimateID, estimateErr := l.latestActivePricelistID("estimate") worldID, worldErr := l.latestActivePricelistID("world") if estimateErr != nil && worldErr != nil { return nil, fmt.Errorf("no active estimate or world pricelist") } // Always a derived table, so every caller can apply its own Select/Count/Where // uniformly and none of them has to know which pricelists actually exist. const estimateSelect = ` SELECT lot_name, lot_category, lot_description, price_quality FROM local_pricelist_items WHERE pricelist_id = ?` const worldSelect = ` SELECT w.lot_name, w.lot_category, w.lot_description, 0 AS price_quality FROM local_pricelist_items w WHERE w.pricelist_id = ?` const worldOnlyTail = ` AND NOT EXISTS ( SELECT 1 FROM local_pricelist_items e WHERE e.pricelist_id = ? AND UPPER(e.lot_name) = UPPER(w.lot_name) )` var inner *gorm.DB switch { case worldErr != nil: inner = l.db.Raw(estimateSelect, estimateID) case estimateErr != nil: inner = l.db.Raw(worldSelect, worldID) default: inner = l.db.Raw( estimateSelect+"\nUNION ALL"+worldSelect+worldOnlyTail, estimateID, worldID, estimateID) } return l.db.Table("(?) AS c", inner), nil } // pricelistItemRow is used for scanning rows from local_pricelist_items. type pricelistItemRow struct { LotName string `gorm:"column:lot_name"` Category string `gorm:"column:lot_category"` Description string `gorm:"column:lot_description"` PriceQuality *int `gorm:"column:price_quality"` } func (r pricelistItemRow) toLocalComponent() LocalComponent { return LocalComponent{ LotName: r.LotName, Category: r.Category, LotDescription: r.Description, PriceQuality: r.PriceQuality, } } // SearchLocalComponents searches the component universe by lot_name. func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) { if limit <= 0 { limit = 50 } db, err := l.componentUniverse() if err != nil { return nil, err } if query != "" { db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%") } var rows []pricelistItemRow if err := db.Select("lot_name, lot_category, lot_description, price_quality"). Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil { return nil, err } components := make([]LocalComponent, len(rows)) for i, r := range rows { components[i] = r.toLocalComponent() } return components, nil } // SearchLocalComponentsByCategory searches the component universe filtered by category. func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) { if limit <= 0 { limit = 50 } db, err := l.componentUniverse() if err != nil { return nil, err } db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(category)) if query != "" { db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%") } var rows []pricelistItemRow if err := db.Select("lot_name, lot_category, lot_description, price_quality"). Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil { return nil, err } components := make([]LocalComponent, len(rows)) for i, r := range rows { components[i] = r.toLocalComponent() } return components, nil } // ListComponents returns components from the component universe with optional // category/search filtering and pagination. func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { db, err := l.componentUniverse() if err != nil { return nil, 0, err } if filter.Category != "" { db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category)) } if filter.Search != "" { db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(filter.Search)+"%") } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var rows []pricelistItemRow if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil { return nil, 0, err } components := make([]LocalComponent, len(rows)) for i, r := range rows { components[i] = r.toLocalComponent() } return components, total, nil } // GetLocalComponent returns a single component by lot_name from the component universe. func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { db, err := l.componentUniverse() if err != nil { return nil, err } var row pricelistItemRow if err := db. Select("lot_name, lot_category, lot_description, price_quality"). Where("UPPER(lot_name) = ?", strings.ToUpper(lotName)). First(&row).Error; err != nil { return nil, err } c := row.toLocalComponent() return &c, nil } // GetLocalComponentCategoriesByLotNames returns category for each lot_name // from the component universe. func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) { result := make(map[string]string, len(lotNames)) if len(lotNames) == 0 { return result, nil } db, err := l.componentUniverse() if err != nil { return result, nil } // Build uppercase → original mapping so result keys match what the caller passed. upperToOrig := make(map[string]string, len(lotNames)) upper := make([]string, len(lotNames)) for i, n := range lotNames { u := strings.ToUpper(n) upper[i] = u upperToOrig[u] = n } var rows []pricelistItemRow if err := db. Select("lot_name, lot_category"). Where("UPPER(lot_name) IN ?", upper). Scan(&rows).Error; err != nil { return nil, err } for _, r := range rows { orig := upperToOrig[strings.ToUpper(r.LotName)] if orig == "" { orig = r.LotName } result[orig] = r.Category } return result, nil } // GetLocalComponentCategories returns distinct categories from the component universe. func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { db, err := l.componentUniverse() if err != nil { return nil, err } var categories []string if err := db. Where("lot_category != ''"). Distinct("lot_category"). Order("lot_category"). Pluck("lot_category", &categories).Error; err != nil { return nil, err } return categories, nil } // CountComponents returns the number of lot names in the component universe // (used to check if data is available). func (l *LocalDB) CountComponents() int64 { db, err := l.componentUniverse() if err != nil { return 0 } var count int64 db.Count(&count) return count }