diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 191bfee..e35de47 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1720,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) { var remainingErrors []string for _, change := range erroredChanges { + var modified bool var repairErr error switch change.EntityType { case "project": - repairErr = l.repairProjectChange(&change) + modified, repairErr = l.repairProjectChange(&change) case "configuration": - repairErr = l.repairConfigurationChange(&change) + modified, repairErr = l.repairConfigurationChange(&change) default: repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType) } @@ -1736,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) { continue } - // Clear error and reset attempts + // Only reset attempts when the repair actually changed local data. + // If nothing was modified, the error is server-side; leaving attempts + // intact lets maxPendingChangeAttempts eventually abandon the change. + if !modified { + continue + } + if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{ "last_error": "", "attempts": 0, @@ -1752,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) { } // repairProjectChange validates and fixes project data. +// Returns (modified, err): modified=true only when local data was actually changed. // Note: This only validates local data. Server-side conflicts (like duplicate code+variant) // are handled by sync service layer with deduplication logic. -func (l *LocalDB) repairProjectChange(change *PendingChange) error { +func (l *LocalDB) repairProjectChange(change *PendingChange) (bool, error) { project, err := l.GetProjectByUUID(change.EntityUUID) if err != nil { - return fmt.Errorf("project not found locally: %w", err) + return false, fmt.Errorf("project not found locally: %w", err) } modified := false @@ -1783,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error { if strings.TrimSpace(project.OwnerUsername) == "" { project.OwnerUsername = l.GetDBUser() if project.OwnerUsername == "" { - return fmt.Errorf("cannot determine owner username") + return false, fmt.Errorf("cannot determine owner username") } modified = true } @@ -1804,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error { if modified { if err := l.SaveProject(project); err != nil { - return fmt.Errorf("saving repaired project: %w", err) + return false, fmt.Errorf("saving repaired project: %w", err) } } - return nil + return modified, nil } -// repairConfigurationChange validates and fixes configuration data -func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { +// repairConfigurationChange validates and fixes configuration data. +// Returns (modified, err): modified=true only when local data was actually changed. +func (l *LocalDB) repairConfigurationChange(change *PendingChange) (bool, error) { config, err := l.GetConfigurationByUUID(change.EntityUUID) if err != nil { - return fmt.Errorf("configuration not found locally: %w", err) + return false, fmt.Errorf("configuration not found locally: %w", err) } modified := false @@ -1827,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { // Project doesn't exist locally - use default system project systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername) if sysErr != nil { - return fmt.Errorf("getting system project: %w", sysErr) + return false, fmt.Errorf("getting system project: %w", sysErr) } config.ProjectUUID = &systemProject.UUID modified = true @@ -1836,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { if modified { if err := l.SaveConfiguration(config); err != nil { - return fmt.Errorf("saving repaired configuration: %w", err) + return false, fmt.Errorf("saving repaired configuration: %w", err) } } - return nil + return modified, nil } // GetSyncGuardState returns the latest readiness guard state. diff --git a/internal/repository/project.go b/internal/repository/project.go index 57682ed..d878e1a 100644 --- a/internal/repository/project.go +++ b/internal/repository/project.go @@ -23,6 +23,11 @@ func (r *ProjectRepository) Update(project *models.Project) error { } func (r *ProjectRepository) UpsertByUUID(project *models.Project) error { + // Clear the client-side primary key so the upsert is driven purely by the + // uuid unique constraint. Passing a non-zero ID can trigger ON DUPLICATE KEY + // on the primary key of an unrelated row, leaving uuid unchanged and causing + // the follow-up SELECT to return ErrRecordNotFound. + project.ID = 0 if err := r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "uuid"}}, DoUpdates: clause.AssignmentColumns([]string{ diff --git a/web/templates/index.html b/web/templates/index.html index 2884c76..6269654 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -417,7 +417,7 @@ let TAB_CONFIG = { let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) .flatMap(t => t.categories) - .map(c => c.toUpperCase()); + .map(c => ciStr(c)); // State let configUUID = '{{.ConfigUUID}}'; @@ -760,16 +760,16 @@ async function loadCategoriesFromAPI() { // Build category order map categoryOrderMap = {}; cats.forEach(cat => { - categoryOrderMap[cat.code.toUpperCase()] = cat.display_order; + categoryOrderMap[ciStr(cat.code)] = cat.display_order; }); // Build list of unassigned categories const knownCodes = Object.values(TAB_CONFIG) .flatMap(t => t.categories) - .map(c => c.toUpperCase()); + .map(c => ciStr(c)); const unassignedCategories = cats - .filter(cat => !knownCodes.includes(cat.code.toUpperCase())) + .filter(cat => !knownCodes.includes(ciStr(cat.code))) .sort((a, b) => a.display_order - b.display_order) .map(cat => cat.code); @@ -779,7 +779,7 @@ async function loadCategoriesFromAPI() { // Rebuild ASSIGNED_CATEGORIES ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) .flatMap(t => t.categories) - .map(c => c.toUpperCase()); + .map(c => ciStr(c)); } catch(e) { console.error('Failed to load categories, using defaults', e); // Will use default configuration if API fails @@ -824,7 +824,7 @@ function applyServerSettings(settings) { }; }); TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' }; - ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase()); + ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => ciStr(c)); } // always_visible_tabs @@ -953,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() { loadAllComponents(), categoriesPromise, ]); + cart = cart.map(item => ({ + ...item, + category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || '' + })); syncPriceSettingsControls(); renderPricelistSettingsSummary(); updateRefreshPricesButtonState(); @@ -1218,14 +1222,16 @@ function applyPriceSettings() { schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true }); } +function ciStr(s) { return (s || '').toLowerCase(); } + function getComponentCategory(comp) { - return (comp.category || '').toUpperCase(); + return comp.category || ''; } function getTabForCategory(category) { - const cat = category.toUpperCase(); + const cat = ciStr(category); for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) { - if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) { + if (tabConfig.categories.some(c => ciStr(c) === cat)) { return tabKey; } } @@ -1306,7 +1312,7 @@ function applyConfigTypeToTabs() { // Rebuild assigned categories index ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) .flatMap(t => t.categories) - .map(c => c.toUpperCase()); + .map(c => ciStr(c)); } function updateTabVisibility() { @@ -1334,15 +1340,15 @@ function getComponentsForTab(tab) { return allComponents.filter(comp => { const category = getComponentCategory(comp); if (tab === 'other') { - return !ASSIGNED_CATEGORIES.includes(category); + return !ASSIGNED_CATEGORIES.includes(ciStr(category)); } - return config.categories.map(c => c.toUpperCase()).includes(category); + return config.categories.some(c => ciStr(c) === ciStr(category)); }); } function getComponentsForCategory(category) { return allComponents.filter(comp => { - return getComponentCategory(comp) === category.toUpperCase(); + return ciStr(getComponentCategory(comp)) === ciStr(category); }); } @@ -1404,7 +1410,7 @@ 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).toUpperCase() === cat.toUpperCase() + ciStr(item.category) === ciStr(cat) ); const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null; @@ -1457,9 +1463,7 @@ function renderSingleSelectTab(categories) { function renderMultiSelectTab(components) { // Get cart items for this tab const tabItems = cart.filter(item => { - const cat = (item.category).toUpperCase(); - const tab = getTabForCategory(cat); - return tab === currentTab; + return getTabForCategory(item.category) === currentTab; }); let html = ` @@ -1546,9 +1550,7 @@ function renderMultiSelectTab(components) { function renderMultiSelectTabWithSections(sections) { // Get cart items for this tab const tabItems = cart.filter(item => { - const cat = (item.category).toUpperCase(); - const tab = getTabForCategory(cat); - return tab === currentTab; + return getTabForCategory(item.category) === currentTab; }); let html = ''; @@ -1556,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) { sections.forEach((section, sectionIdx) => { // Get components for this section's categories - const sectionCategories = section.categories.map(c => c.toUpperCase()); const sectionComponents = allComponents.filter(comp => { - const category = getComponentCategory(comp); - return sectionCategories.includes(category); + return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp))); }); totalComponents += sectionComponents.length; // Get cart items for this section const sectionItems = tabItems.filter(item => { - const cat = (item.category).toUpperCase(); - return sectionCategories.includes(cat); + return sectionCategories.some(c => ciStr(c) === ciStr(item.category)); }); // Section header @@ -1806,7 +1805,7 @@ function selectAutocompleteItem(index) { // Remove existing item of this category cart = cart.filter(item => - (item.category).toUpperCase() !== autocompleteCategory.toUpperCase() + ciStr(item.category) !== ciStr(autocompleteCategory) ); const qtyInput = document.getElementById('qty-' + autocompleteCategory); @@ -2189,7 +2188,7 @@ function selectAutocompleteItemBOM(index, rowIdx) { function clearSingleSelect(category) { cart = cart.filter(item => - (item.category).toUpperCase() !== category.toUpperCase() + ciStr(item.category) !== ciStr(category) ); renderTab(); updateCartUI(); @@ -2199,7 +2198,7 @@ function clearSingleSelect(category) { function updateSingleQuantity(category, value) { const qty = parseInt(value) || 1; const item = cart.find(i => - (i.category).toUpperCase() === category.toUpperCase() + ciStr(i.category) === ciStr(category) ); if (item) { @@ -2258,8 +2257,8 @@ function updateCartUI() { // Sort cart items by category display order const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category).toUpperCase(); - const catB = (b.category).toUpperCase(); + const catA = ciStr(a.category); + const catB = ciStr(b.category); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -2267,8 +2266,7 @@ function updateCartUI() { const grouped = {}; sortedCart.forEach(item => { - const cat = item.category; - const tab = getTabForCategory(cat); + const tab = getTabForCategory(item.category); if (!grouped[tab]) grouped[tab] = []; grouped[tab].push(item); }); @@ -2276,11 +2274,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).toUpperCase(); + const cat = ciStr(item.category); return categoryOrderMap[cat] || 9999; })); const minOrderB = Math.min(...b[1].map(item => { - const cat = (item.category).toUpperCase(); + const cat = ciStr(item.category); return categoryOrderMap[cat] || 9999; })); return minOrderA - minOrderB; @@ -2731,8 +2729,8 @@ function renderSalePriceTable() { } const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category).toUpperCase(); - const catB = (b.category).toUpperCase(); + const catA = ciStr(a.category); + const catB = ciStr(b.category); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -2835,8 +2833,8 @@ function calculateCustomPrice() { // Build adjusted prices table // Sort cart items by category display order const sortedCart = [...cart].sort((a, b) => { - const catA = (a.category).toUpperCase(); - const catB = (b.category).toUpperCase(); + const catA = ciStr(a.category); + const catB = ciStr(b.category); const orderA = categoryOrderMap[catA] || 9999; const orderB = categoryOrderMap[catB] || 9999; return orderA - orderB; @@ -4146,8 +4144,8 @@ async function renderPricingTab() { if (!bomRows.length) { const sortedByCategory = [...cart].sort((a, b) => { - const catA = (a.category).toUpperCase(); - const catB = (b.category).toUpperCase(); + const catA = ciStr(a.category); + const catB = ciStr(b.category); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); }); sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });