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