diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 5c3a608..19aacc3 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -116,6 +116,14 @@ func New(dbPath string) (*LocalDB, error) { return nil, fmt.Errorf("opening sqlite database: %w", err) } + // Enable WAL mode so background sync writes never block UI reads. + if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil { + slog.Warn("failed to enable WAL mode", "error", err) + } + if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil { + slog.Warn("failed to set synchronous=NORMAL", "error", err) + } + if err := ensureLocalProjectsTable(db); err != nil { return nil, fmt.Errorf("ensure local_projects table: %w", err) } @@ -1152,6 +1160,29 @@ func (l *LocalDB) CountLocalPricelists() int64 { return count } +// CountAllPricelistItems returns total rows across all local_pricelist_items. +func (l *LocalDB) CountAllPricelistItems() int64 { + var count int64 + l.db.Model(&LocalPricelistItem{}).Count(&count) + return count +} + +// CountComponents returns the number of rows in local_components. +func (l *LocalDB) CountComponents() int64 { + var count int64 + l.db.Model(&LocalComponent{}).Count(&count) + return count +} + +// DBFileSizeBytes returns the size of the SQLite database file in bytes. +func (l *LocalDB) DBFileSizeBytes() int64 { + info, err := os.Stat(l.path) + if err != nil { + return 0 + } + return info.Size() +} + // GetLatestLocalPricelist returns the most recently synced pricelist func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { var pricelist LocalPricelist @@ -1319,6 +1350,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64 return item.Price, nil } +// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query. +// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result. +func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { + result := make(map[string]float64, len(lotNames)) + if len(lotNames) == 0 { + return result, nil + } + type row struct { + LotName string `gorm:"column:lot_name"` + Price float64 `gorm:"column:price"` + } + var rows []row + if err := l.db.Model(&LocalPricelistItem{}). + Select("lot_name, price"). + Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames). + Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if r.Price > 0 { + result[r.LotName] = r.Price + } + } + return result, nil +} + // GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID. // Missing lots are not included in the map; caller is responsible for strict validation. func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) { diff --git a/internal/services/quote.go b/internal/services/quote.go index f7dc0db..9cef5ca 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -388,13 +388,14 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st } } - // Fallback path (usually offline): local per-lot lookup. + // Fallback path (usually offline): batch local lookup (single query via index). if s.localDB != nil { - for _, lotName := range missing { - price, found := s.lookupPriceByPricelistID(pricelistID, lotName) - if found && price > 0 { - result[lotName] = price - loaded[lotName] = price + if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil { + if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil { + for lotName, price := range batchPrices { + result[lotName] = price + loaded[lotName] = price + } } } s.updateCache(pricelistID, missing, loaded) diff --git a/internal/services/sync/readiness.go b/internal/services/sync/readiness.go index 7f5add6..f270d41 100644 --- a/internal/services/sync/readiness.go +++ b/internal/services/sync/readiness.go @@ -168,6 +168,10 @@ func ensureClientSchemaStateTable(db *gorm.DB) error { "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code", + "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text", + "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count", + "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count", + "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count", } { if err := db.Exec(stmt).Error; err != nil { return fmt.Errorf("expand qt_client_schema_state: %w", err) @@ -215,6 +219,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) warehouseVersion := latestPricelistVersion(s.localDB, "warehouse") competitorVersion := latestPricelistVersion(s.localDB, "competitor") lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB) + localPricelistCount := s.localDB.CountLocalPricelists() + pricelistItemsCount := s.localDB.CountAllPricelistItems() + componentsCount := s.localDB.CountComponents() + dbSizeBytes := s.localDB.DBFileSizeBytes() return mariaDB.Exec(` INSERT INTO qt_client_schema_state ( username, hostname, app_version, @@ -222,9 +230,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) configurations_count, projects_count, estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version, last_sync_error_code, last_sync_error_text, + local_pricelist_count, pricelist_items_count, components_count, db_size_bytes, last_checked_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE app_version = VALUES(app_version), last_sync_at = VALUES(last_sync_at), @@ -238,6 +247,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) competitor_pricelist_version = VALUES(competitor_pricelist_version), last_sync_error_code = VALUES(last_sync_error_code), last_sync_error_text = VALUES(last_sync_error_text), + local_pricelist_count = VALUES(local_pricelist_count), + pricelist_items_count = VALUES(pricelist_items_count), + components_count = VALUES(components_count), + db_size_bytes = VALUES(db_size_bytes), last_checked_at = VALUES(last_checked_at), updated_at = VALUES(updated_at) `, username, hostname, appmeta.Version(), @@ -245,6 +258,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) configurationsCount, projectsCount, estimateVersion, warehouseVersion, competitorVersion, lastSyncErrorCode, lastSyncErrorText, + localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes, checkedAt, checkedAt).Error }