diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 9846164..ad1b97f 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1147,20 +1147,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i return count, nil } -// SaveLocalPricelistItems saves pricelist items to local SQLite +// SaveLocalPricelistItems saves pricelist items to local SQLite. +// Duplicate (pricelist_id, lot_name) rows are silently ignored. func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { if len(items) == 0 { return nil } - // Batch insert batchSize := 500 for i := 0; i < len(items); i += batchSize { end := i + batchSize if end > len(items) { end = len(items) } - if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { + if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil { return err } } diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index e820a1b..e8e9437 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -119,6 +119,11 @@ var localMigrations = []localMigration{ name: "Convert local partnumber book cache to book membership + deduplicated PN catalog", run: migrateLocalPartnumberBookCatalog, }, + { + id: "2026_03_13_pricelist_items_dedup_unique", + name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)", + run: deduplicatePricelistItemsAndAddUniqueIndex, + }, } type localPartnumberCatalogRow struct { @@ -1092,3 +1097,26 @@ func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPar } return nil } + +func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error { + // Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id. + if err := tx.Exec(` + DELETE FROM local_pricelist_items + WHERE id NOT IN ( + SELECT MIN(id) FROM local_pricelist_items + GROUP BY pricelist_id, lot_name + ) + `).Error; err != nil { + return fmt.Errorf("deduplicate local_pricelist_items: %w", err) + } + + // Add unique index to prevent future duplicates. + if err := tx.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique + ON local_pricelist_items(pricelist_id, lot_name) + `).Error; err != nil { + return fmt.Errorf("create unique index on local_pricelist_items: %w", err) + } + slog.Info("deduplicated local_pricelist_items and added unique index") + return nil +} diff --git a/web/templates/index.html b/web/templates/index.html index 6320b2e..991833f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -225,7 +225,7 @@ — — — - — + — @@ -3546,8 +3546,8 @@ async function renderPricingTab() { } catch(e) { /* silent — pricing tab renders with available data */ } } - let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0; - let hasVendor = false, hasEstimate = false, hasWarehouse = false; + let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0, totalCompetitor = 0; + let hasVendor = false, hasEstimate = false, hasWarehouse = false, hasCompetitor = false; tbody.innerHTML = ''; @@ -3563,10 +3563,13 @@ async function renderPricingTab() { const pl = priceMap[item.lot_name]; const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0); const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null; + const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null; const estimateTotal = estUnit * item.quantity; const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null; + const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null; if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; } if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; } + if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; } tr.dataset.est = estimateTotal; const desc = (compMap[item.lot_name] || {}).description || ''; tr.dataset.vendorOrig = ''; @@ -3578,7 +3581,7 @@ async function renderPricingTab() { ${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'} — ${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'} - — + ${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'} `; tbody.appendChild(tr); }); @@ -3598,10 +3601,13 @@ async function renderPricingTab() { let hasEstimateForRow = false; let rowWarehouse = 0; let hasWarehouseForRow = false; + let rowCompetitor = 0; + let hasCompetitorForRow = false; if (baseLot) { const pl = priceMap[baseLot]; const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null; const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null; + const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null; if (estimateUnit != null) { rowEst += estimateUnit * row.quantity * _getRowLotQtyPerPN(row); hasEstimateForRow = true; @@ -3610,11 +3616,16 @@ async function renderPricingTab() { rowWarehouse += warehouseUnit * row.quantity * _getRowLotQtyPerPN(row); hasWarehouseForRow = true; } + if (competitorUnit != null) { + rowCompetitor += competitorUnit * row.quantity * _getRowLotQtyPerPN(row); + hasCompetitorForRow = true; + } } allocs.forEach(a => { const pl = priceMap[a.lot_name]; const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null; const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null; + const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null; if (estimateUnit != null) { rowEst += estimateUnit * row.quantity * a.quantity; hasEstimateForRow = true; @@ -3623,12 +3634,17 @@ async function renderPricingTab() { rowWarehouse += warehouseUnit * row.quantity * a.quantity; hasWarehouseForRow = true; } + if (competitorUnit != null) { + rowCompetitor += competitorUnit * row.quantity * a.quantity; + hasCompetitorForRow = true; + } }); const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null); if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; } if (hasEstimateForRow) { totalEstimate += rowEst; hasEstimate = true; } if (hasWarehouseForRow) { totalWarehouse += rowWarehouse; hasWarehouse = true; } + if (hasCompetitorForRow) { totalCompetitor += rowCompetitor; hasCompetitor = true; } tr.dataset.est = rowEst; tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : ''; @@ -3649,7 +3665,7 @@ async function renderPricingTab() { ${hasEstimateForRow ? formatCurrency(rowEst) : '—'} ${vendorTotal != null ? formatCurrency(vendorTotal) : '—'} ${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'} - — + ${hasCompetitorForRow ? formatCurrency(rowCompetitor) : '—'} `; tbody.appendChild(tr); }); @@ -3663,10 +3679,13 @@ async function renderPricingTab() { const pl = priceMap[item.lot_name]; const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0); const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null; + const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null; const estimateTotal = estUnit * item.quantity; const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null; + const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null; if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; } if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; } + if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; } tr.dataset.est = estimateTotal; tr.dataset.vendorOrig = ''; const desc = (compMap[item.lot_name] || {}).description || ''; @@ -3678,7 +3697,7 @@ async function renderPricingTab() { ${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'} — ${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'} - — + ${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'} `; tbody.appendChild(tr); }); @@ -3688,6 +3707,7 @@ async function renderPricingTab() { document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—'; document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—'; document.getElementById('pricing-total-warehouse').textContent = hasWarehouse ? formatCurrency(totalWarehouse) : '—'; + document.getElementById('pricing-total-competitor').textContent = hasCompetitor ? formatCurrency(totalCompetitor) : '—'; tfoot.classList.remove('hidden'); // Update custom price proportional breakdown