fix: регистронезависимый поиск lot_name и удаление мёртвого кода
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1120,3 +1120,4 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
|
||||
slog.Info("deduplicated local_pricelist_items and added unique index")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user