From 7d190cc7a874ee35fee8578a133ad0397f265ff5 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Fri, 26 Jun 2026 08:52:22 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20lot=5Fnam?= =?UTF-8?q?e=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQLite-запросы по lot_name теперь используют UPPER(lot_name) IN/= для совместимости с легаси-данными, синхронизированными до нормализации регистра - Удалена таблица local_components и весь связанный код синхронизации; источник данных для компонентов — local_pricelist_items - Удалена функция getCategoryFromLotName из JS: категория берётся только из прайслиста, без инференса из имени лота - Регистронезависимые сравнения lot_name в JS (warehouse stock set, addedLots, cartLots, allComponents.find, _bomLotValid) - В support bundle добавлены: latest_pricelist_items.json, local.db, autocomplete_lots.json для диагностики Co-Authored-By: Claude Sonnet 4.6 --- cmd/qfs/main.go | 1 - internal/article/categories_test.go | 45 ++- internal/handlers/pricelist.go | 12 +- internal/handlers/support_bundle.go | 107 +++++- internal/handlers/sync.go | 101 +----- internal/handlers/vendor_spec.go | 4 +- internal/localdb/components.go | 486 ++++++++++------------------ internal/localdb/converters.go | 4 +- internal/localdb/localdb.go | 129 ++++---- internal/localdb/migrations.go | 1 + internal/localdb/models.go | 13 +- internal/localdb/qt_settings.go | 16 +- internal/models/lot.go | 8 + internal/repository/pricelist.go | 15 +- internal/services/export.go | 12 +- internal/services/quote.go | 19 +- internal/services/sync/service.go | 28 +- internal/services/sync/worker.go | 5 - web/templates/index.html | 79 +++-- 19 files changed, 500 insertions(+), 585 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 3cf1470..e9f2aa7 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1811,7 +1811,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect syncAPI.GET("/readiness", syncHandler.GetReadiness) syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/users-status", syncHandler.GetUsersStatus) - syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks) syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen) diff --git a/internal/article/categories_test.go b/internal/article/categories_test.go index eb8c027..1df668c 100644 --- a/internal/article/categories_test.go +++ b/internal/article/categories_test.go @@ -45,38 +45,55 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) { } } -func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) { +func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) { local, err := localdb.New(filepath.Join(t.TempDir(), "local.db")) if err != nil { t.Fatalf("init local db: %v", err) } t.Cleanup(func() { _ = local.Close() }) + // Older pricelist used by the configuration — CPU_B has no category here if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ ServerID: 2, Source: "estimate", Version: "S-2026-02-11-002", - Name: "test", + Name: "old", + IsActive: false, + CreatedAt: time.Now().Add(-time.Hour), + SyncedAt: time.Now().Add(-time.Hour), + }); err != nil { + t.Fatalf("save old pricelist: %v", err) + } + oldPL, err := local.GetLocalPricelistByServerID(2) + if err != nil { + t.Fatalf("get old pricelist: %v", err) + } + if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ + {PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10}, + }); err != nil { + t.Fatalf("save old pricelist items: %v", err) + } + + // Newer active pricelist — CPU_B has category set + if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ + ServerID: 3, + Source: "estimate", + Version: "S-2026-02-11-003", + Name: "latest", + IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(), }); err != nil { - t.Fatalf("save local pricelist: %v", err) + t.Fatalf("save latest pricelist: %v", err) } - localPL, err := local.GetLocalPricelistByServerID(2) + latestPL, err := local.GetLocalPricelistByServerID(3) if err != nil { - t.Fatalf("get local pricelist: %v", err) + t.Fatalf("get latest pricelist: %v", err) } if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ - {PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10}, + {PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10}, }); err != nil { - t.Fatalf("save local items: %v", err) - } - if err := local.DB().Create(&localdb.LocalComponent{ - LotName: "CPU_B", - Category: "CPU", - LotDescription: "cpu", - }).Error; err != nil { - t.Fatalf("save local components: %v", err) + t.Fatalf("save latest pricelist items: %v", err) } cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"}) diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index 565947a..0cfbf81 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -177,22 +177,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { return } - lotNames := make([]string, len(items)) - for i, item := range items { - lotNames[i] = item.LotName - } - descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames) - if err != nil { - RespondError(c, http.StatusInternalServerError, "internal server error", err) - return - } - resultItems := make([]gin.H, 0, len(items)) for _, item := range items { resultItems = append(resultItems, gin.H{ "id": item.ID, "lot_name": item.LotName, - "lot_description": descMap[item.LotName], + "lot_description": "", "price": item.Price, "category": item.LotCategory, "available_qty": item.AvailableQty, diff --git a/internal/handlers/support_bundle.go b/internal/handlers/support_bundle.go index fc6e44b..5549ce9 100644 --- a/internal/handlers/support_bundle.go +++ b/internal/handlers/support_bundle.go @@ -74,7 +74,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { // local_db_stats.json writeJSON("local_db_stats.json", map[string]any{ - "components": h.localDB.CountLocalComponents(), + "components": h.localDB.CountComponents(), "configurations": h.localDB.CountConfigurations(), "projects": h.localDB.CountProjects(), "pricelists": h.localDB.CountLocalPricelists(), @@ -139,6 +139,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { CreatedAt time.Time `json:"created_at"` SyncedAt time.Time `json:"synced_at"` IsUsed bool `json:"is_used"` + IsActive bool `json:"is_active"` } bySource := map[string][]plEntry{} for _, pl := range pricelists { @@ -150,12 +151,78 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { CreatedAt: pl.CreatedAt, SyncedAt: pl.SyncedAt, IsUsed: pl.IsUsed, + IsActive: pl.IsActive, } bySource[pl.Source] = append(bySource[pl.Source], e) } writeJSON("pricelists.json", bySource) } + // pricelist_coverage.json — for each local estimate pricelist: item count by lot_category + if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { + type catRow struct { + Category string `json:"category"` + Count int64 `json:"count"` + } + type plCoverage struct { + Version string `json:"version"` + ServerID uint `json:"server_id"` + TotalItems int64 `json:"total_items"` + Categories []catRow `json:"categories"` + } + rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID) + if catErr == nil { + cats := make([]catRow, 0, len(rows)) + for cat, cnt := range rows { + cats = append(cats, catRow{Category: cat, Count: cnt}) + } + writeJSON("pricelist_coverage.json", plCoverage{ + Version: pl.Version, + ServerID: pl.ServerID, + TotalItems: total, + Categories: cats, + }) + } + } + + // configurator_settings.json — what /api/configurator-settings actually returns + if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil { + writeJSON("configurator_settings.json", cfgSettings) + } else { + writeJSON("configurator_settings.json", map[string]any{"error": err.Error()}) + } + + // component_categories.json — distinct categories in active estimate pricelist + if cats, err := h.localDB.GetLocalComponentCategories(); err == nil { + writeJSON("component_categories.json", cats) + } + + // autocomplete_lots.json — per-category breakdown of lots with their prices + // Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category. + if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { + if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil { + type lotEntry struct { + LotName string `json:"lot_name"` + Price float64 `json:"price"` + HasPrice bool `json:"has_price"` + } + byCategory := map[string][]lotEntry{} + for _, it := range items { + entry := lotEntry{ + LotName: it.LotName, + Price: it.Price, + HasPrice: it.Price > 0, + } + byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry) + } + writeJSON("autocomplete_lots.json", map[string]any{ + "pricelist_version": pl.Version, + "pricelist_id": pl.ServerID, + "by_category": byCategory, + }) + } + } + // schema_migrations.json migrations, err := h.localDB.GetSchemaMigrations() if err != nil { @@ -163,6 +230,44 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { } writeJSON("schema_migrations.json", migrations) + // latest_pricelist_items.json — all items from the most recent active estimate pricelist + if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { + if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil { + type plItem struct { + LotName string `json:"lot_name"` + LotCategory string `json:"lot_category"` + Price float64 `json:"price"` + } + out := make([]plItem, len(items)) + for i, it := range items { + out[i] = plItem{ + LotName: it.LotName, + LotCategory: it.LotCategory, + Price: it.Price, + } + } + writeJSON("latest_pricelist_items.json", map[string]any{ + "pricelist_version": pl.Version, + "pricelist_id": pl.ServerID, + "source": pl.Source, + "item_count": len(out), + "items": out, + }) + } + } + + // local.db — full SQLite database file (for deep diagnostics) + if dbPath := h.localDB.DBFilePath(); dbPath != "" { + if f, err := os.Open(dbPath); err == nil { + defer f.Close() + if w, err := zw.Create("local.db"); err == nil { + if _, err := io.Copy(w, f); err != nil { + slog.Warn("support bundle: error copying local.db", "err", err) + } + } + } + } + // app.log (tail 5 MiB) if h.logFilePath != "" { if f, err := os.Open(h.logFilePath); err == nil { diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 8560bc2..366a772 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -50,7 +50,6 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr // SyncStatusResponse represents the sync status type SyncStatusResponse struct { - LastComponentSync *time.Time `json:"last_component_sync"` LastPricelistSync *time.Time `json:"last_pricelist_sync"` LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"` LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"` @@ -61,7 +60,6 @@ type SyncStatusResponse struct { ComponentsCount int64 `json:"components_count"` PricelistsCount int64 `json:"pricelists_count"` ServerPricelists int `json:"server_pricelists"` - NeedComponentSync bool `json:"need_component_sync"` NeedPricelistSync bool `json:"need_pricelist_sync"` Readiness *sync.SyncReadiness `json:"readiness,omitempty"` } @@ -80,19 +78,16 @@ type SyncReadinessResponse struct { func (h *SyncHandler) GetStatus(c *gin.Context) { connStatus := h.connMgr.GetStatus() isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == "" - lastComponentSync := h.localDB.GetComponentSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime() - componentsCount := h.localDB.CountLocalComponents() + componentsCount := h.localDB.CountComponents() pricelistsCount := h.localDB.CountLocalPricelists() lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") - needComponentSync := h.localDB.NeedComponentSync(24) readiness := h.getReadinessLocal() c.JSON(http.StatusOK, SyncStatusResponse{ - LastComponentSync: lastComponentSync, LastPricelistSync: lastPricelistSync, LastPricelistAttemptAt: lastPricelistAttemptAt, LastPricelistSyncStatus: lastPricelistSyncStatus, @@ -103,7 +98,6 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { ComponentsCount: componentsCount, PricelistsCount: pricelistsCount, ServerPricelists: 0, - NeedComponentSync: needComponentSync, NeedPricelistSync: lastPricelistSync == nil || hasFailedSync, Readiness: readiness, }) @@ -169,52 +163,6 @@ type SyncResultResponse struct { Duration string `json:"duration"` } -// SyncComponents syncs components from MariaDB to local SQLite -// POST /api/sync/components -func (h *SyncHandler) SyncComponents(c *gin.Context) { - if !h.ensureSyncReadiness(c) { - return - } - - // Get database connection from ConnectionManager - mariaDB, err := h.connMgr.GetDB() - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "database connection failed", - }) - _ = c.Error(err) - return - } - - now := time.Now() - result, err := h.localDB.SyncComponents(mariaDB) - if err != nil { - _ = h.localDB.SetComponentSyncResult("error", err.Error(), now) - h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds()) - slog.Error("component sync failed", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": "component sync failed", - }) - _ = c.Error(err) - return - } - _ = h.localDB.SetComponentSyncResult("ok", "", now) - h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds()) - - if err := h.localDB.SyncQtSettings(mariaDB); err != nil { - slog.Warn("qt_settings sync failed", "error", err) - } - - c.JSON(http.StatusOK, SyncResultResponse{ - Success: true, - Message: "Components synced successfully", - Synced: result.TotalSynced, - Duration: result.Duration.String(), - }) -} - // SyncPricelists syncs pricelists from MariaDB to local SQLite // POST /api/sync/pricelists func (h *SyncHandler) SyncPricelists(c *gin.Context) { @@ -280,7 +228,6 @@ type SyncAllResponse struct { Success bool `json:"success"` Message string `json:"message"` PendingPushed int `json:"pending_pushed"` - ComponentsSynced int `json:"components_synced"` PricelistsSynced int `json:"pricelists_synced"` ProjectsImported int `json:"projects_imported"` ProjectsUpdated int `json:"projects_updated"` @@ -301,7 +248,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { } startTime := time.Now() - var pendingPushed, componentsSynced, pricelistsSynced int + var pricelistsSynced int // Push local pending changes first (projects/configurations) pendingPushed, err := h.syncService.PushPendingChanges() @@ -315,38 +262,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { return } - // Sync components - mariaDB, err := h.connMgr.GetDB() - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "success": false, - "error": "database connection failed", - }) - _ = c.Error(err) - return - } - - compNow := time.Now() - compResult, err := h.localDB.SyncComponents(mariaDB) - if err != nil { - _ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow) - h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds()) - slog.Error("component sync failed during full sync", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": "component sync failed", - }) - _ = c.Error(err) - return - } - _ = h.localDB.SetComponentSyncResult("ok", "", compNow) - h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds()) - componentsSynced = compResult.TotalSynced - - if err := h.localDB.SyncQtSettings(mariaDB); err != nil { - slog.Warn("qt_settings sync failed", "error", err) - } - // Sync pricelists plNow := time.Now() pricelistsSynced, err = h.syncService.SyncPricelists() @@ -354,10 +269,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds()) slog.Error("pricelist sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": "pricelist sync failed", - "pending_pushed": pendingPushed, - "components_synced": componentsSynced, + "success": false, + "error": "pricelist sync failed", + "pending_pushed": pendingPushed, }) _ = c.Error(err) return @@ -375,7 +289,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { "success": false, "error": "project import failed", "pending_pushed": pendingPushed, - "components_synced": componentsSynced, "pricelists_synced": pricelistsSynced, }) _ = c.Error(err) @@ -389,7 +302,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { "success": false, "error": "configuration import failed", "pending_pushed": pendingPushed, - "components_synced": componentsSynced, "pricelists_synced": pricelistsSynced, "projects_imported": projectsResult.Imported, "projects_updated": projectsResult.Updated, @@ -403,7 +315,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { Success: true, Message: "Full sync completed successfully", PendingPushed: pendingPushed, - ComponentsSynced: componentsSynced, PricelistsSynced: pricelistsSynced, ProjectsImported: projectsResult.Imported, ProjectsUpdated: projectsResult.Updated, @@ -564,7 +475,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { // Get local counts configCount := h.localDB.CountConfigurations() projectCount := h.localDB.CountProjects() - componentCount := h.localDB.CountLocalComponents() + componentCount := h.localDB.CountComponents() pricelistCount := h.localDB.CountLocalPricelists() // Get error count (only changes with LastError != "") diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index cf26334..300a6ab 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -4,9 +4,9 @@ import ( "errors" "log/slog" "net/http" - "strings" "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" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" @@ -172,7 +172,7 @@ func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpe merged := make(map[string]int, len(in)) order := make([]string, 0, len(in)) for _, m := range in { - lot := strings.TrimSpace(m.LotName) + lot := models.NormalizeLotName(m.LotName) if lot == "" { continue } diff --git a/internal/localdb/components.go b/internal/localdb/components.go index 41363d5..dcb190e 100644 --- a/internal/localdb/components.go +++ b/internal/localdb/components.go @@ -2,11 +2,8 @@ package localdb import ( "fmt" - "log/slog" "strings" "time" - - "gorm.io/gorm" ) // ComponentFilter for searching with filters @@ -24,344 +21,213 @@ type ComponentSyncResult struct { Duration time.Duration } -// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components -func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { - startTime := time.Now() - - // Build the component catalog from every runtime source of LOT names. - // Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot, - // so the sync cannot start from lot alone. - type componentRow struct { - LotName string - LotDescription string - Category *string - Model *string - } - - var rows []componentRow - err := mariaDB.Raw(` - SELECT - src.lot_name, - COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description, - COALESCE( - MAX(NULLIF(TRIM(c.code), '')), - MAX(NULLIF(TRIM(l.lot_category), '')), - SUBSTRING_INDEX(src.lot_name, '_', 1) - ) AS category, - MAX(NULLIF(TRIM(m.model), '')) AS model - FROM ( - SELECT lot_name FROM lot - UNION - SELECT lot_name FROM qt_lot_metadata - WHERE is_hidden = FALSE OR is_hidden IS NULL - UNION - SELECT lot_name FROM qt_pricelist_items - ) src - LEFT JOIN lot l ON l.lot_name = src.lot_name - LEFT JOIN qt_lot_metadata m - ON m.lot_name = src.lot_name - AND (m.is_hidden = FALSE OR m.is_hidden IS NULL) - LEFT JOIN qt_categories c ON m.category_id = c.id - GROUP BY src.lot_name - ORDER BY src.lot_name - `).Scan(&rows).Error +// 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 nil, fmt.Errorf("querying components from MariaDB: %w", err) + return 0, err } - - if len(rows) == 0 { - slog.Warn("no components found in MariaDB") - return &ComponentSyncResult{ - Duration: time.Since(startTime), - }, nil + if id == 0 { + return 0, fmt.Errorf("no active %s pricelist", source) } - - // Get existing local components for comparison - existingMap := make(map[string]bool) - var existing []LocalComponent - if err := l.db.Find(&existing).Error; err != nil { - return nil, fmt.Errorf("reading existing local components: %w", err) - } - for _, c := range existing { - existingMap[c.LotName] = true - } - - // Prepare components for batch insert/update. - // Source joins may duplicate the same lot_name, so collapse them before insert. - syncTime := time.Now() - components := make([]LocalComponent, 0, len(rows)) - componentIndex := make(map[string]int, len(rows)) - newCount := 0 - - for _, row := range rows { - lotName := strings.TrimSpace(row.LotName) - if lotName == "" { - continue - } - - category := "" - if row.Category != nil { - category = strings.TrimSpace(*row.Category) - } else { - // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") - parts := strings.SplitN(lotName, "_", 2) - if len(parts) >= 1 { - category = parts[0] - } - } - - model := "" - if row.Model != nil { - model = strings.TrimSpace(*row.Model) - } - - comp := LocalComponent{ - LotName: lotName, - LotDescription: strings.TrimSpace(row.LotDescription), - Category: category, - Model: model, - } - - if idx, exists := componentIndex[lotName]; exists { - // Keep the first row, but fill any missing metadata from duplicates. - if components[idx].LotDescription == "" && comp.LotDescription != "" { - components[idx].LotDescription = comp.LotDescription - } - if components[idx].Category == "" && comp.Category != "" { - components[idx].Category = comp.Category - } - if components[idx].Model == "" && comp.Model != "" { - components[idx].Model = comp.Model - } - continue - } - - componentIndex[lotName] = len(components) - components = append(components, comp) - - if !existingMap[lotName] { - newCount++ - } - } - - // Use transaction for bulk upsert - err = l.db.Transaction(func(tx *gorm.DB) error { - // Delete all existing and insert new (simpler than upsert for SQLite) - if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil { - return fmt.Errorf("clearing local components: %w", err) - } - - // Batch insert - batchSize := 500 - for i := 0; i < len(components); i += batchSize { - end := i + batchSize - if end > len(components) { - end = len(components) - } - if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil { - return fmt.Errorf("inserting components batch: %w", err) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - // Update last sync time - if err := l.SetComponentSyncTime(syncTime); err != nil { - slog.Warn("failed to update component sync time", "error", err) - } - - result := &ComponentSyncResult{ - TotalSynced: len(components), - NewCount: newCount, - UpdateCount: len(components) - newCount, - Duration: time.Since(startTime), - } - - slog.Info("components synced", - "total", result.TotalSynced, - "new", result.NewCount, - "updated", result.UpdateCount, - "duration", result.Duration) - - return result, nil + return id, nil } -// SearchLocalComponents searches components in local cache by query string -// Searches in lot_name, lot_description, category, and model fields +// 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"` +} + +func (r pricelistItemRow) toLocalComponent() LocalComponent { + return LocalComponent{ + LotName: r.LotName, + Category: r.Category, + } +} + + +// SearchLocalComponents searches components in the latest active estimate +// pricelist by lot_name. func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) { if limit <= 0 { limit = 50 } - - var components []LocalComponent - - if query == "" { - // Return all components with limit - err := l.db.Order("lot_name").Limit(limit).Find(&components).Error - return components, err - } - - // Search with LIKE on multiple fields - searchPattern := "%" + strings.ToLower(query) + "%" - - err := l.db.Where( - "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern, - ).Order("lot_name").Limit(limit).Find(&components).Error - - return components, err -} - -// SearchLocalComponentsByCategory searches components by category and optional query -func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) { - if limit <= 0 { - limit = 50 - } - - var components []LocalComponent - db := l.db.Where("LOWER(category) = ?", strings.ToLower(category)) - - if query != "" { - searchPattern := "%" + strings.ToLower(query) + "%" - db = db.Where( - "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?", - searchPattern, searchPattern, searchPattern, - ) - } - - err := db.Order("lot_name").Limit(limit).Find(&components).Error - return components, err -} - -// ListComponents returns components with filtering and pagination -func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { - db := l.db - - // Apply category filter - if filter.Category != "" { - db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category)) - } - - // Apply search filter - if filter.Search != "" { - searchPattern := "%" + strings.ToLower(filter.Search) + "%" - db = db.Where( - "LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern, - ) - } - - // Get total count - var total int64 - if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - // Apply pagination and get results - var components []LocalComponent - if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil { - return nil, 0, err - } - - return components, total, nil -} - -// GetLocalComponent returns a single component by lot_name -func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { - var component LocalComponent - err := l.db.Where("lot_name = ?", lotName).First(&component).Error + pricelistID, err := l.latestActivePricelistID("estimate") if err != nil { return nil, err } - return &component, nil + + db := l.db.Table("local_pricelist_items"). + Where("pricelist_id = ?", pricelistID) + if query != "" { + db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%") + } + + var rows []pricelistItemRow + if err := db.Select("lot_name, lot_category").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 } -// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache. -// Missing lots are not included in the map; caller is responsible for strict validation. +// SearchLocalComponentsByCategory searches components in the latest active +// estimate pricelist filtered by category. +func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) { + if limit <= 0 { + limit = 50 + } + pricelistID, err := l.latestActivePricelistID("estimate") + if err != nil { + return nil, err + } + + db := l.db.Table("local_pricelist_items"). + Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, 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").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 latest active estimate pricelist +// with optional category/search filtering and pagination. +func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { + pricelistID, err := l.latestActivePricelistID("estimate") + if err != nil { + return nil, 0, err + } + + db := l.db.Table("local_pricelist_items"). + Where("pricelist_id = ?", pricelistID) + + 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").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 latest +// active estimate pricelist. +func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { + pricelistID, err := l.latestActivePricelistID("estimate") + if err != nil { + return nil, err + } + + var row pricelistItemRow + if err := l.db.Table("local_pricelist_items"). + Select("lot_name, lot_category"). + Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, 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 latest active estimate pricelist. func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) { result := make(map[string]string, len(lotNames)) if len(lotNames) == 0 { return result, nil } - - type row struct { - LotName string `gorm:"column:lot_name"` - Category string `gorm:"column:category"` + pricelistID, err := l.latestActivePricelistID("estimate") + if err != nil { + return result, nil } - var rows []row - if err := l.db.Model(&LocalComponent{}). - Select("lot_name, category"). - Where("lot_name IN ?", lotNames). - Find(&rows).Error; err != 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 := l.db.Table("local_pricelist_items"). + Select("lot_name, lot_category"). + Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper). + Scan(&rows).Error; err != nil { return nil, err } for _, r := range rows { - result[r.LotName] = r.Category + orig := upperToOrig[strings.ToUpper(r.LotName)] + if orig == "" { + orig = r.LotName + } + result[orig] = r.Category } return result, nil } -// GetLocalComponentCategories returns distinct categories from local components +// GetLocalComponentCategories returns distinct categories from the latest +// active estimate pricelist. func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { - var categories []string - err := l.db.Model(&LocalComponent{}). - Distinct("category"). - Where("category != ''"). - Order("category"). - Pluck("category", &categories).Error - return categories, err -} - -// CountLocalComponents returns the total number of local components -func (l *LocalDB) CountLocalComponents() int64 { - var count int64 - l.db.Model(&LocalComponent{}).Count(&count) - return count -} - -// CountLocalComponentsByCategory returns component count by category -func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 { - var count int64 - l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count) - return count -} - -// GetComponentSyncTime returns the last component sync timestamp -func (l *LocalDB) GetComponentSyncTime() *time.Time { - var setting struct { - Value string - } - if err := l.db.Table("app_settings"). - Where("key = ?", "last_component_sync"). - First(&setting).Error; err != nil { - return nil - } - - t, err := time.Parse(time.RFC3339, setting.Value) + pricelistID, err := l.latestActivePricelistID("estimate") if err != nil { - return nil + return nil, err } - return &t + + var categories []string + if err := l.db.Table("local_pricelist_items"). + Where("pricelist_id = ? AND lot_category != ''", pricelistID). + Distinct("lot_category"). + Order("lot_category"). + Pluck("lot_category", &categories).Error; err != nil { + return nil, err + } + return categories, nil } -// SetComponentSyncTime sets the last component sync timestamp -func (l *LocalDB) SetComponentSyncTime(t time.Time) error { - return l.db.Exec(` - INSERT INTO app_settings (key, value, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error +// CountComponents returns the number of distinct lot names in the latest +// active estimate pricelist (used to check if data is available). +func (l *LocalDB) CountComponents() int64 { + pricelistID, err := l.latestActivePricelistID("estimate") + if err != nil { + return 0 + } + var count int64 + l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count) + return count } -// NeedComponentSync checks if component sync is needed (older than specified hours) -func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool { - syncTime := l.GetComponentSyncTime() - if syncTime == nil { - return true - } - return time.Since(*syncTime).Hours() > float64(maxAgeHours) -} diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index f0bdf2a..8a6c355 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -11,7 +11,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { items := make(LocalConfigItems, len(cfg.Items)) for i, item := range cfg.Items { items[i] = LocalConfigItem{ - LotName: item.LotName, + LotName: models.NormalizeLotName(item.LotName), Quantity: item.Quantity, UnitPrice: item.UnitPrice, } @@ -271,7 +271,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo partnumbers = append(partnumbers, item.Partnumbers...) return &LocalPricelistItem{ PricelistID: localPricelistID, - LotName: item.LotName, + LotName: models.NormalizeLotName(item.LotName), LotCategory: item.LotCategory, Price: item.Price, AvailableQty: item.AvailableQty, diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 10f7bca..191bfee 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -46,7 +46,6 @@ type LocalDB struct { var localReadOnlyCacheTables = []string{ "local_pricelist_items", "local_pricelists", - "local_components", "local_partnumber_book_items", "local_partnumber_books", } @@ -78,7 +77,6 @@ func ResetData(dbPath string) error { "local_configuration_versions", "local_pricelists", "local_pricelist_items", - "local_components", "local_sync_guard_state", "pending_changes", "app_settings", @@ -224,7 +222,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error { &LocalConfigurationVersion{}, &LocalPricelist{}, &LocalPricelistItem{}, - &LocalComponent{}, &AppSetting{}, &LocalSyncGuardState{}, &PendingChange{}, @@ -1237,25 +1234,6 @@ func (l *LocalDB) GetLastComponentSyncError() string { return strings.TrimSpace(value) } -func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error { - status = strings.TrimSpace(status) - errorText = strings.TrimSpace(errorText) - if status == "" { - status = "unknown" - } - return l.db.Transaction(func(tx *gorm.DB) error { - if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil { - return err - } - if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil { - return err - } - if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil { - return err - } - return nil - }) -} // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { @@ -1271,11 +1249,10 @@ func (l *LocalDB) CountAllPricelistItems() int64 { 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 + +// DBFilePath returns the path to the SQLite database file. +func (l *LocalDB) DBFilePath() string { + return l.path } // DBFileSizeBytes returns the size of the SQLite database file in bytes. @@ -1287,11 +1264,11 @@ func (l *LocalDB) DBFileSizeBytes() int64 { return info.Size() } -// GetLatestLocalPricelist returns the most recently synced pricelist +// GetLatestLocalPricelist returns the most recently synced active estimate pricelist. func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db. - Where("source = ?", "estimate"). + Where("source = ? AND is_active = ?", "estimate", true). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Order("created_at DESC, id DESC"). First(&pricelist).Error; err != nil { @@ -1300,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { return &pricelist, nil } -// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source. +// GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source. func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) { var pricelist LocalPricelist if err := l.db. - Where("source = ?", source). + Where("source = ? AND is_active = ?", source, true). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Order("created_at DESC, id DESC"). First(&pricelist).Error; err != nil { @@ -1313,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis return &pricelist, nil } +// DeactivateLocalPricelistsNotIn marks all local pricelists with is_active=true whose +// server_id is not in activeServerIDs as inactive. Used after each pricelist sync to +// mirror server-side deactivations locally. +func (l *LocalDB) DeactivateLocalPricelistsNotIn(activeServerIDs []uint) error { + q := l.db.Model(&LocalPricelist{}).Where("is_active = ?", true) + if len(activeServerIDs) > 0 { + q = q.Where("server_id NOT IN ?", activeServerIDs) + } + return q.Update("is_active", false).Error +} + // GetLocalPricelistByServerID returns a local pricelist by its server ID func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) { var pricelist LocalPricelist @@ -1380,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 { return count } +// GetLocalPricelistCoverageByCategory returns item count per lot_category and the total +// for the given local pricelist ID. Only items with price > 0 are counted. +func (l *LocalDB) GetLocalPricelistCoverageByCategory(pricelistID uint) (map[string]int64, int64, error) { + type row struct { + Category string `gorm:"column:lot_category"` + Count int64 `gorm:"column:cnt"` + } + var rows []row + if err := l.db.Model(&LocalPricelistItem{}). + Select("COALESCE(NULLIF(TRIM(lot_category),''), '?') AS lot_category, COUNT(*) AS cnt"). + Where("pricelist_id = ? AND price > 0", pricelistID). + Group("lot_category"). + Scan(&rows).Error; err != nil { + return nil, 0, err + } + result := make(map[string]int64, len(rows)) + var total int64 + for _, r := range rows { + result[r.Category] = r.Count + total += r.Count + } + return result, total, nil +} + // CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category. func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) { var count int64 @@ -1444,10 +1456,11 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem return items, nil } -// GetLocalPriceForLot returns the price for a lot from a local pricelist +// GetLocalPriceForLot returns the price for a lot from a local pricelist. +// Matching is case-insensitive via UPPER(lot_name) to handle legacy mixed-case rows. func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) { var item LocalPricelistItem - if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName). + if err := l.db.Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)). First(&item).Error; err != nil { return 0, err } @@ -1455,26 +1468,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64 } // 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. +// Missing lots are omitted from the result. +// lotNames must already be normalized (uppercased); matching is done via UPPER(lot_name) to handle +// legacy rows that were stored in mixed case before normalization was enforced at sync time. +// Keys in the returned map are uppercased (matching the input lotNames). 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 + // Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched. if err := l.db.Model(&LocalPricelistItem{}). Select("lot_name, price"). - Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames). + Where("pricelist_id = ? AND UPPER(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 + // Key must be uppercase to match callers that normalise lot names before lookup. + result[strings.ToUpper(r.LotName)] = r.Price } } return result, nil @@ -1497,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin LotName string `gorm:"column:lot_name"` LotCategory string `gorm:"column:lot_category"` } + // 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 []row if err := l.db.Model(&LocalPricelistItem{}). Select("lot_name, lot_category"). - Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames). + Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper). Find(&rows).Error; err != nil { return nil, err } for _, r := range rows { - result[r.LotName] = r.LotCategory + orig := upperToOrig[strings.ToUpper(r.LotName)] + if orig == "" { + orig = r.LotName + } + result[orig] = r.LotCategory } return result, nil } @@ -1871,28 +1902,6 @@ func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, pa return items, total, nil } -// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots. -func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) { - if len(lotNames) == 0 { - return map[string]string{}, nil - } - type row struct { - LotName string - LotDescription string - } - var rows []row - if err := l.db.Table("local_components"). - Select("lot_name, lot_description"). - Where("lot_name IN ?", lotNames). - Scan(&rows).Error; err != nil { - return nil, fmt.Errorf("fetch component descriptions: %w", err) - } - m := make(map[string]string, len(rows)) - for _, r := range rows { - m[r.LotName] = r.LotDescription - } - return m, nil -} // GetSchemaMigrations returns all applied local schema migrations ordered by applied_at. func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) { diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index e8e9437..ef1f6be 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -1120,3 +1120,4 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error { slog.Info("deduplicated local_pricelist_items and added unique index") return nil } + diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 8862f84..f17ed30 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "time" + + "git.mchus.pro/mchus/quoteforge/internal/models" ) // AppSetting stores application settings in local SQLite @@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error { default: return errors.New("type assertion failed for LocalConfigItems") } - return json.Unmarshal(bytes, c) + if err := json.Unmarshal(bytes, c); err != nil { + return err + } + for i := range *c { + (*c)[i].LotName = models.NormalizeLotName((*c)[i].LotName) + } + return nil } func (c LocalConfigItems) Total() float64 { @@ -169,7 +177,8 @@ type LocalPricelist struct { Name string `json:"name"` CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"` SyncedAt time.Time `json:"synced_at"` - IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration + IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration + IsActive bool `gorm:"not null;default:true;index" json:"is_active"` // Mirrors qt_pricelists.is_active } func (LocalPricelist) TableName() string { diff --git a/internal/localdb/qt_settings.go b/internal/localdb/qt_settings.go index 5260e1f..a65f537 100644 --- a/internal/localdb/qt_settings.go +++ b/internal/localdb/qt_settings.go @@ -42,25 +42,29 @@ type ConfiguratorSettings struct { } // SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the -// local_qt_settings cache in a single SQLite transaction. Returns an error if -// the qt_settings table doesn't exist on the server (old server without the -// table) or on any query/write failure. +// local_qt_settings cache in a single SQLite transaction. +// If the read fails (no connection, table missing on old server) or the server +// returns an empty table, the existing local_qt_settings are preserved so the +// configurator keeps working offline or against old server versions. func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error { var rows []LocalQtSetting if err := mariaDB. Table("qt_settings"). Select("name, value"). Find(&rows).Error; err != nil { + slog.Warn("qt_settings: read from MariaDB failed, keeping existing local cache", "error", err) return fmt.Errorf("reading qt_settings from MariaDB: %w", err) } + if len(rows) == 0 { + slog.Warn("qt_settings: server returned empty table, keeping existing local cache") + return nil + } + return l.db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil { return fmt.Errorf("clearing local_qt_settings: %w", err) } - if len(rows) == 0 { - return nil - } if err := tx.Create(&rows).Error; err != nil { return fmt.Errorf("inserting local_qt_settings: %w", err) } diff --git a/internal/models/lot.go b/internal/models/lot.go index bcbe472..c31d8c2 100644 --- a/internal/models/lot.go +++ b/internal/models/lot.go @@ -1,5 +1,13 @@ package models +import "strings" + +// NormalizeLotName returns the canonical form of a lot name: trimmed and uppercased. +// Apply at every point where a lot name enters the system (sync, API input, config load). +func NormalizeLotName(s string) string { + return strings.ToUpper(strings.TrimSpace(s)) +} + // Lot represents existing lot table type Lot struct { LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"` diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 25b2d5d..a100629 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -269,12 +269,21 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) ( } // GetPricesForLots returns price map for given lots within a pricelist. +// Keys in the returned map match the requested lot names (case-preserving) so that +// callers using Go map lookups are not confused by case differences between the +// requested name and the stored value (e.g. pricelist renamed lots to UPPERCASE). func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { result := make(map[string]float64, len(lotNames)) if pricelistID == 0 || len(lotNames) == 0 { return result, nil } + // Build case-insensitive index: lowercase → original requested name. + lotIndex := make(map[string]string, len(lotNames)) + for _, n := range lotNames { + lotIndex[strings.ToLower(n)] = n + } + var rows []models.PricelistItem if err := r.db.Select("lot_name, price"). Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames). @@ -284,7 +293,11 @@ func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []stri for _, row := range rows { if row.Price > 0 { - result[row.LotName] = row.Price + key := row.LotName + if requested, ok := lotIndex[strings.ToLower(row.LotName)]; ok { + key = requested + } + result[key] = row.Price } } return result, nil diff --git a/internal/services/export.go b/internal/services/export.go index d76b61d..2c05698 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -656,16 +656,8 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string return prices } -func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string { - lots := collectPricingLots(cfg, localCfg, true) - if s.localDB == nil || len(lots) == 0 { - return map[string]string{} - } - descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots) - if err != nil { - return map[string]string{} - } - return descriptions +func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string { + return map[string]string{} } func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { diff --git a/internal/services/quote.go b/internal/services/quote.go index 12cab8b..b8bd373 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -111,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation if len(req.Items) == 0 { return nil, ErrEmptyQuote } + for i := range req.Items { + req.Items[i].LotName = models.NormalizeLotName(req.Items[i].LotName) + } // Strict local-first path: calculations use local SQLite snapshot regardless of online status. if s.localDB != nil { @@ -245,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve if len(req.Items) == 0 { return nil, ErrEmptyQuote } + // Keep original lot names so the response mirrors what the caller sent. + // Normalization is applied only for internal DB lookups. + originalLotNames := make(map[string]string, len(req.Items)) + for i := range req.Items { + upper := models.NormalizeLotName(req.Items[i].LotName) + if _, exists := originalLotNames[upper]; !exists { + originalLotNames[upper] = req.Items[i].LotName + } + req.Items[i].LotName = upper + } lotNames := make([]string, 0, len(req.Items)) seenLots := make(map[string]struct{}, len(req.Items)) @@ -303,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve } for _, reqItem := range req.Items { + responseLotName := originalLotNames[reqItem.LotName] + if responseLotName == "" { + responseLotName = reqItem.LotName + } item := PriceLevelsItem{ - LotName: reqItem.LotName, + LotName: responseLotName, Quantity: reqItem.Quantity, PriceMissing: make([]string, 0, 3), } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index fe8e32f..53bd30b 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -404,6 +404,7 @@ func (s *Service) syncPricelists() (int, error) { CreatedAt: pl.CreatedAt, SyncedAt: time.Now(), IsUsed: false, + IsActive: true, } itemCount, err := s.syncNewPricelistSnapshot(localPL) @@ -426,6 +427,12 @@ func (s *Service) syncPricelists() (int, error) { slog.Info("deleted stale local pricelists", "deleted", removed) } + // Mirror server-side deactivations: any local pricelist not in the current active set + // is marked is_active=false so offline lookups skip it. + if err := s.localDB.DeactivateLocalPricelistsNotIn(serverPricelistIDs); err != nil { + slog.Warn("failed to deactivate stale local pricelists", "error", err) + } + // Backfill lot_category for used pricelists (older local caches may miss the column values). s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs) @@ -1638,24 +1645,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus { return s.connMgr.GetStatus() } -// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty. -// Used by the background worker on first run to populate the catalog for new users. -func (s *Service) SyncComponentsIfEmpty() error { - if s.localDB.CountComponents() > 0 { - return nil - } - mariaDB, err := s.getDB() - if err != nil { - _ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now()) - return err - } - result, err := s.localDB.SyncComponents(mariaDB) - now := time.Now() - if err != nil { - _ = s.localDB.SetComponentSyncResult("error", err.Error(), now) - return err - } - _ = s.localDB.SetComponentSyncResult("ok", "", now) - slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced) - return nil -} diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index 1f6b1e4..a2a752e 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -80,11 +80,6 @@ func (w *Worker) runSync() { return } - // Populate component catalog on first run (empty local_components) - if err := w.service.SyncComponentsIfEmpty(); err != nil { - w.logger.Warn("background sync: initial component sync failed", "error", err) - } - // Push pending changes first pushed, err := w.service.PushPendingChanges() if err != nil { diff --git a/web/templates/index.html b/web/templates/index.html index 04f7a08..2884c76 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -713,7 +713,7 @@ async function loadWarehouseInStockLots() { const lotNames = Array.isArray(data.lot_names) ? data.lot_names : []; lotNames.forEach(lot => { if (typeof lot === 'string' && lot.trim() !== '') { - result.add(lot); + result.add(lot.toUpperCase()); } }); @@ -748,7 +748,7 @@ function isComponentAllowedByStockFilter(comp) { const availableLots = warehouseStockLotsByPricelist.get(pricelistID); // Don't block UI while stock set is being loaded. if (!availableLots) return true; - return availableLots.has(comp.lot_name); + return availableLots.has((comp.lot_name || '').toUpperCase()); } // Load categories from API and update tab configuration @@ -853,7 +853,7 @@ function updateRequiredCategoryBadges() { // Build set of categories that have at least one cart item const filledCategories = new Set( - cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase()) + cart.map(item => (item.category || '').toUpperCase()) ); // For each tab, check if it contains any required-but-unfilled category @@ -925,8 +925,7 @@ document.addEventListener('DOMContentLoaded', async function() { warehouse_price: null, competitor_price: null, description: item.description || '', - category: item.category || getCategoryFromLotName(item.lot_name) - })); + category: item.category })); } serverModelForQuote = config.server_model || ''; supportCode = config.support_code || ''; @@ -1003,7 +1002,7 @@ const BOM_LOT_DATALIST_DIVIDER = '────────'; function _bomLotValid(v) { const lot = (v || '').trim(); if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false; - return (window._bomAllComponents || allComponents).some(c => c.lot_name === lot); + return (window._bomAllComponents || allComponents).some(c => c.lot_name.toUpperCase() === lot.toUpperCase()); } function updateServerCount() { @@ -1219,13 +1218,8 @@ function applyPriceSettings() { schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true }); } -function getCategoryFromLotName(lotName) { - const parts = lotName.split('_'); - return parts[0] || ''; -} - function getComponentCategory(comp) { - return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase(); + return (comp.category || '').toUpperCase(); } function getTabForCategory(category) { @@ -1323,7 +1317,7 @@ function updateTabVisibility() { if (!btn) continue; const hasComponents = getComponentsForTab(tabId).length > 0; const hasCartItems = cart.some(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase(); + const cat = (item.category || '').toUpperCase(); return getTabForCategory(cat) === tabId; }); const visible = hasComponents || hasCartItems; @@ -1410,10 +1404,10 @@ function renderSingleSelectTab(categories) { categories.forEach(cat => { const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat; const selectedItem = cart.find(item => - (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase() + (item.category).toUpperCase() === cat.toUpperCase() ); - const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null; + const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null; const price = comp?.current_price || 0; const estimate = selectedItem?.estimate_price ?? price; const qty = selectedItem?.quantity || 1; @@ -1463,7 +1457,7 @@ function renderSingleSelectTab(categories) { function renderMultiSelectTab(components) { // Get cart items for this tab const tabItems = cart.filter(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); + const cat = (item.category).toUpperCase(); const tab = getTabForCategory(cat); return tab === currentTab; }); @@ -1485,7 +1479,7 @@ function renderMultiSelectTab(components) { // Render existing cart items for this tab tabItems.forEach((item, idx) => { - const comp = allComponents.find(c => c.lot_name === item.lot_name); + const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase()); const total = getDisplayPrice(item) * item.quantity; html += ` @@ -1552,7 +1546,7 @@ function renderMultiSelectTab(components) { function renderMultiSelectTabWithSections(sections) { // Get cart items for this tab const tabItems = cart.filter(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); + const cat = (item.category).toUpperCase(); const tab = getTabForCategory(cat); return tab === currentTab; }); @@ -1571,7 +1565,7 @@ function renderMultiSelectTabWithSections(sections) { // Get cart items for this section const sectionItems = tabItems.filter(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); + const cat = (item.category).toUpperCase(); return sectionCategories.includes(cat); }); @@ -1599,7 +1593,7 @@ function renderMultiSelectTabWithSections(sections) { // Render existing cart items for this section sectionItems.forEach((item) => { - const comp = allComponents.find(c => c.lot_name === item.lot_name); + const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase()); const total = getDisplayPrice(item) * item.quantity; html += ` @@ -1812,7 +1806,7 @@ function selectAutocompleteItem(index) { // Remove existing item of this category cart = cart.filter(item => - (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase() + (item.category).toUpperCase() !== autocompleteCategory.toUpperCase() ); const qtyInput = document.getElementById('qty-' + autocompleteCategory); @@ -1868,11 +1862,11 @@ function filterAutocompleteMulti(search) { const searchLower = search.toLowerCase(); // Filter out already added items - const addedLots = new Set(cart.map(i => i.lot_name)); + const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase())); autocompleteFiltered = components.filter(c => { if (!hasComponentPrice(c.lot_name)) return false; - if (addedLots.has(c.lot_name)) return false; + if (addedLots.has((c.lot_name || '').toUpperCase())) return false; if (!isComponentAllowedByStockFilter(c)) return false; const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); return text.includes(searchLower); @@ -1973,11 +1967,11 @@ function filterAutocompleteSection(sectionId, search, inputElement) { }); // Filter out already added items - const addedLots = new Set(cart.map(i => i.lot_name)); + const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase())); autocompleteFiltered = sectionComponents.filter(c => { if (!hasComponentPrice(c.lot_name)) return false; - if (addedLots.has(c.lot_name)) return false; + if (addedLots.has((c.lot_name || '').toUpperCase())) return false; if (!isComponentAllowedByStockFilter(c)) return false; const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); return text.includes(searchLower); @@ -2143,14 +2137,14 @@ function showAutocompleteBOM(rowIdx, input) { function filterAutocompleteBOM(rowIdx, search) { const searchLower = (search || '').toLowerCase(); - const cartLots = new Set(cart.map(i => i.lot_name)); + const cartLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase())); const all = (window._bomAllComponents || allComponents).filter(c => { const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); return text.includes(searchLower); }); - const inCart = all.filter(c => cartLots.has(c.lot_name)) + const inCart = all.filter(c => cartLots.has((c.lot_name || '').toUpperCase())) .sort((a, b) => a.lot_name.localeCompare(b.lot_name)); - const notInCart = all.filter(c => !cartLots.has(c.lot_name)) + const notInCart = all.filter(c => !cartLots.has((c.lot_name || '').toUpperCase())) .sort((a, b) => { const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0); if (popDiff !== 0) return popDiff; @@ -2195,7 +2189,7 @@ function selectAutocompleteItemBOM(index, rowIdx) { function clearSingleSelect(category) { cart = cart.filter(item => - (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase() + (item.category).toUpperCase() !== category.toUpperCase() ); renderTab(); updateCartUI(); @@ -2205,7 +2199,7 @@ function clearSingleSelect(category) { function updateSingleQuantity(category, value) { const qty = parseInt(value) || 1; const item = cart.find(i => - (i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase() + (i.category).toUpperCase() === category.toUpperCase() ); if (item) { @@ -2264,8 +2258,8 @@ function updateCartUI() { // Sort cart items by category display order const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); - const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); + const catA = (a.category).toUpperCase(); + const catB = (b.category).toUpperCase(); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -2273,7 +2267,7 @@ function updateCartUI() { const grouped = {}; sortedCart.forEach(item => { - const cat = item.category || getCategoryFromLotName(item.lot_name); + const cat = item.category; const tab = getTabForCategory(cat); if (!grouped[tab]) grouped[tab] = []; grouped[tab].push(item); @@ -2282,11 +2276,11 @@ function updateCartUI() { // Sort tabs by minimum display order of their categories const sortedTabs = Object.entries(grouped).sort((a, b) => { const minOrderA = Math.min(...a[1].map(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); + const cat = (item.category).toUpperCase(); return categoryOrderMap[cat] || 9999; })); const minOrderB = Math.min(...b[1].map(item => { - const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); + const cat = (item.category).toUpperCase(); return categoryOrderMap[cat] || 9999; })); return minOrderA - minOrderB; @@ -2517,8 +2511,7 @@ function restoreAutosaveDraftIfAny() { warehouse_price: null, competitor_price: null, description: item.description || '', - category: item.category || getCategoryFromLotName(item.lot_name) - })); + category: item.category })); } if (typeof payload.server_count === 'number' && payload.server_count > 0) { serverCount = payload.server_count; @@ -2738,8 +2731,8 @@ function renderSalePriceTable() { } const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); - const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); + const catA = (a.category).toUpperCase(); + const catB = (b.category).toUpperCase(); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -2842,8 +2835,8 @@ function calculateCustomPrice() { // Build adjusted prices table // Sort cart items by category display order const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); - const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); + const catA = (a.category).toUpperCase(); + const catB = (b.category).toUpperCase(); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -4153,8 +4146,8 @@ async function renderPricingTab() { if (!bomRows.length) { const sortedByCategory = [...cart].sort((a, b) => { - const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); - const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); + const catA = (a.category).toUpperCase(); + const catB = (b.category).toUpperCase(); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); }); sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });