diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 300a6ab..ab8601b 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -6,7 +6,6 @@ import ( "net/http" "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" @@ -100,7 +99,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { body.VendorSpec[i].SortOrder = (i + 1) * 10 } // Persist canonical LOT mapping only. - body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings) + body.VendorSpec[i].LotMappings = localdb.NormalizeLotMappings(body.VendorSpec[i].LotMappings) body.VendorSpec[i].ResolvedLotName = "" body.VendorSpec[i].ResolutionSource = "" body.VendorSpec[i].ManualLotSuggestion = "" @@ -165,39 +164,6 @@ func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) { } } -func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { - if len(in) == 0 { - return nil - } - merged := make(map[string]int, len(in)) - order := make([]string, 0, len(in)) - for _, m := range in { - lot := models.NormalizeLotName(m.LotName) - if lot == "" { - continue - } - qty := m.QuantityPerPN - if qty < 1 { - qty = 1 - } - if _, exists := merged[lot]; !exists { - order = append(order, lot) - } - merged[lot] += qty - } - out := make([]localdb.VendorSpecLotMapping, 0, len(order)) - for _, lot := range order { - out = append(out, localdb.VendorSpecLotMapping{ - LotName: lot, - QuantityPerPN: merged[lot], - }) - } - if len(out) == 0 { - return nil - } - return out -} - // ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. // POST /api/configs/:uuid/vendor-spec/resolve func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index de0347a..92ba8ca 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -6,6 +6,42 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/models" ) +// NormalizeLotMappings is the single canonical normalizer for vendor BOM LOT +// mappings. LOT names are canonicalized to their uppercase form (see +// models.NormalizeLotName) so that all BOM↔cart matching is case-insensitive, +// duplicate LOTs are merged (summing quantity-per-PN), and quantities are at +// least 1. Returns nil for an empty result. Both the persistence path +// (handlers) and the CSV export path must use this — do not reimplement it. +func NormalizeLotMappings(in []VendorSpecLotMapping) []VendorSpecLotMapping { + if len(in) == 0 { + return nil + } + merged := make(map[string]int, len(in)) + order := make([]string, 0, len(in)) + for _, m := range in { + lot := models.NormalizeLotName(m.LotName) + if lot == "" { + continue + } + qty := m.QuantityPerPN + if qty < 1 { + qty = 1 + } + if _, exists := merged[lot]; !exists { + order = append(order, lot) + } + merged[lot] += qty + } + if len(order) == 0 { + return nil + } + out := make([]VendorSpecLotMapping, 0, len(order)) + for _, lot := range order { + out = append(out, VendorSpecLotMapping{LotName: lot, QuantityPerPN: merged[lot]}) + } + return out +} + // ConfigurationToLocal converts models.Configuration to LocalConfiguration func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { items := make(LocalConfigItems, len(cfg.Items)) diff --git a/internal/localdb/converters_test.go b/internal/localdb/converters_test.go index a14a257..ab1fce1 100644 --- a/internal/localdb/converters_test.go +++ b/internal/localdb/converters_test.go @@ -6,6 +6,36 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/models" ) +func TestNormalizeLotMappings_CaseInsensitiveMerge(t *testing.T) { + in := []VendorSpecLotMapping{ + {LotName: "cpu_intel_6960p", QuantityPerPN: 1}, + {LotName: "CPU_INTEL_6960P", QuantityPerPN: 2}, + {LotName: " ps_5200w_Titanium ", QuantityPerPN: 0}, + {LotName: "", QuantityPerPN: 5}, + } + + out := NormalizeLotMappings(in) + + if len(out) != 2 { + t.Fatalf("expected 2 merged mappings, got %d: %+v", len(out), out) + } + if out[0].LotName != "CPU_INTEL_6960P" || out[0].QuantityPerPN != 3 { + t.Fatalf("expected CPU_INTEL_6960P qty 3, got %+v", out[0]) + } + if out[1].LotName != "PS_5200W_TITANIUM" || out[1].QuantityPerPN != 1 { + t.Fatalf("expected PS_5200W_TITANIUM qty 1 (clamped), got %+v", out[1]) + } +} + +func TestNormalizeLotMappings_Empty(t *testing.T) { + if NormalizeLotMappings(nil) != nil { + t.Fatal("expected nil for empty input") + } + if NormalizeLotMappings([]VendorSpecLotMapping{{LotName: " "}}) != nil { + t.Fatal("expected nil when all entries blank") + } +} + func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) { item := &models.PricelistItem{ LotName: "CPU_A", diff --git a/internal/services/export.go b/internal/services/export.go index 2c05698..14babd8 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 { coveredLots := make(map[string]struct{}) for _, row := range localCfg.VendorSpec { - rowMappings := normalizeLotMappings(row.LotMappings) + rowMappings := localdb.NormalizeLotMappings(row.LotMappings) for _, mapping := range rowMappings { coveredLots[mapping.LotName] = struct{}{} } @@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts } for _, item := range cfg.Items { - if item.LotName == "" { + lot := models.NormalizeLotName(item.LotName) + if lot == "" { continue } - if _, ok := coveredLots[item.LotName]; ok { + if _, ok := coveredLots[lot]; ok { continue } - estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity) + estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity) block.Rows = append(block.Rows, ProjectPricingExportRow{ - LotDisplay: item.LotName, + LotDisplay: lot, VendorPN: "—", - Description: componentDescriptions[item.LotName], + Description: componentDescriptions[lot], Quantity: exportPositiveInt(item.Quantity, 1), Estimate: estimate, - Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), - Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), + Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity), + Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity), }) } if opts.isDDP() { @@ -665,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig out := make([]string, 0) if includeBOM && localCfg != nil { for _, row := range localCfg.VendorSpec { - for _, mapping := range normalizeLotMappings(row.LotMappings) { + for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) { if _, ok := seen[mapping.LotName]; ok { continue } @@ -688,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig return out } -func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { - if len(mappings) == 0 { - return nil - } - out := make([]localdb.VendorSpecLotMapping, 0, len(mappings)) - for _, mapping := range mappings { - lot := strings.TrimSpace(mapping.LotName) - if lot == "" { - continue - } - qty := mapping.QuantityPerPN - if qty < 1 { - qty = 1 - } - out = append(out, localdb.VendorSpecLotMapping{ - LotName: lot, - QuantityPerPN: qty, - }) - } - return out -} - func vendorRowTotal(row localdb.VendorSpecItem) *float64 { if row.TotalPrice != nil { return floatPtr(*row.TotalPrice) @@ -720,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 { return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1))) } -func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 { - if len(mappings) == 0 { - return nil - } - total := 0.0 - hasValue := false - qty := exportPositiveInt(pnQty, 1) - for _, mapping := range mappings { - price := selector(priceMap[mapping.LotName]) - if price == nil || *price <= 0 { - continue - } - total += *price * float64(qty*mapping.QuantityPerPN) - hasValue = true - } - if !hasValue { - return nil - } - return floatPtr(total) -} - // distributeManualPrice sets ManualPrice on each row proportionally based on the // row's Estimate share. The last row with a price absorbs rounding remainder so // the sum of ManualPrice values always equals manualPrice exactly. diff --git a/web/templates/index.html b/web/templates/index.html index 8f13fae..df71cd8 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3379,8 +3379,9 @@ function setBOMManualLotDraft(rowIdx, value, el) { function _getRowAllocations(row) { const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : []; + // Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend). return list.map(a => ({ - lot_name: (a?.lot_name || '').trim(), + lot_name: (a?.lot_name || '').trim().toUpperCase(), quantity: Math.max(1, parseInt(a?.quantity, 10) || 1) })); } @@ -3389,9 +3390,11 @@ function _getRowLotQtyPerPN(row) { return (Number.isFinite(q) && q > 0) ? q : 1; } function _getRowBaseLot(row) { - if (row?.resolved_lot) return row.resolved_lot; + // Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend). + const resolved = (row?.resolved_lot || '').trim(); + if (resolved) return resolved.toUpperCase(); const manual = (row?.manual_lot || '').trim(); - if (manual && _bomLotValid(manual)) return manual; + if (manual && _bomLotValid(manual)) return manual.toUpperCase(); return ''; } function _getRowCanonicalLotMappings(row) { @@ -4039,40 +4042,39 @@ async function renderPricingTab() { const tfootSale = document.getElementById('pricing-foot-sale'); const cart = window._currentCart || []; + // Canonical LOT key: matching/dedup must be case-insensitive (cart is UPPERCASE, + // BOM mappings may be mixed-case). See NormalizeLotName on the backend. + const U = s => (s || '').toUpperCase(); const compMap = {}; - (window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; }); - const rowBaseLot = (row) => { - if (row?.resolved_lot) return row.resolved_lot; - if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot; - return ''; - }; + (window._bomAllComponents || allComponents).forEach(c => { compMap[U(c.lot_name)] = c; }); + const rowBaseLot = (row) => _getRowBaseLot(row); // Collect LOTs to price: from BOM rows (resolved) or from cart // Use cart quantity when available (source of truth); fall back to BOM-computed quantity. const _cartQtyMap = {}; - cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; }); + cart.forEach(item => { if (item?.lot_name) _cartQtyMap[U(item.lot_name)] = item.quantity; }); let itemsForPriceLevels = []; if (bomRows.length) { const seen = new Set(); bomRows.forEach(row => { const baseLot = rowBaseLot(row); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); - if (baseLot && !seen.has(baseLot)) { - seen.add(baseLot); - itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) }); + if (baseLot && !seen.has(U(baseLot))) { + seen.add(U(baseLot)); + itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[U(baseLot)] ?? (row.quantity * _getRowLotQtyPerPN(row)) }); } if (allocs.length) { allocs.forEach(a => { - if (!seen.has(a.lot_name)) { - seen.add(a.lot_name); - itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) }); + if (!seen.has(U(a.lot_name))) { + seen.add(U(a.lot_name)); + itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity) }); } }); } }); cart.forEach(item => { - if (!item?.lot_name || seen.has(item.lot_name)) return; - seen.add(item.lot_name); + if (!item?.lot_name || seen.has(U(item.lot_name))) return; + seen.add(U(item.lot_name)); itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity }); }); } else { @@ -4097,7 +4099,7 @@ async function renderPricingTab() { }); if (resp.ok) { const data = await resp.json(); - (data.items || []).forEach(i => { priceMap[i.lot_name] = i; }); + (data.items || []).forEach(i => { priceMap[U(i.lot_name)] = i; }); } } catch(e) { /* silent */ } } @@ -4119,19 +4121,19 @@ async function renderPricingTab() { // ─── Build shared row data (unit prices for display, totals for math) ──── // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. const cartQtyMap = {}; - cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; }); + cart.forEach(item => { if (item?.lot_name) cartQtyMap[U(item.lot_name)] = item.quantity; }); const _buildRows = () => { const result = []; const coveredLots = new Set(); const _pushCartRow = (item, isEstOnly) => { - const pl = priceMap[item.lot_name]; + const pl = priceMap[U(item.lot_name)]; const u = _getUnitPrices(pl); const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0); result.push({ lotCell: escapeHtml(item.lot_name), lotText: item.lot_name, vendorPN: null, - desc: (compMap[item.lot_name] || {}).description || '', + desc: (compMap[U(item.lot_name)] || {}).description || '', qty: item.quantity, estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit, est: estUnit * item.quantity, @@ -4148,28 +4150,28 @@ async function renderPricingTab() { const catB = ciStr(b.category); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); }); - sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); + sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(U(item.lot_name)); }); return { result, coveredLots }; } bomRows.forEach(row => { const baseLot = rowBaseLot(row); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); - if (baseLot) coveredLots.add(baseLot); - allocs.forEach(a => coveredLots.add(a.lot_name)); + if (baseLot) coveredLots.add(U(baseLot)); + allocs.forEach(a => coveredLots.add(U(a.lot_name))); const vendorOrigUnit = row.unit_price != null ? row.unit_price : (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null); const vendorOrig = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null); - const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : ''); + const desc = row.description || (baseLot ? ((compMap[U(baseLot)] || {}).description || '') : ''); // Build per-LOT sub-rows const subRows = []; if (baseLot) { - const u = _getUnitPrices(priceMap[baseLot]); + const u = _getUnitPrices(priceMap[U(baseLot)]); const lotQty = _getRowLotQtyPerPN(row); - const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty); + const qty = cartQtyMap[U(baseLot)] ?? (row.quantity * lotQty); subRows.push({ lotCell: escapeHtml(baseLot), lotText: baseLot, qty, estUnit: u.estUnit > 0 ? u.estUnit : 0, @@ -4180,8 +4182,8 @@ async function renderPricingTab() { }); } allocs.forEach(a => { - const u = _getUnitPrices(priceMap[a.lot_name]); - const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity); + const u = _getUnitPrices(priceMap[U(a.lot_name)]); + const qty = cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity); subRows.push({ lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty, estUnit: u.estUnit > 0 ? u.estUnit : 0, @@ -4223,9 +4225,9 @@ async function renderPricingTab() { // Estimate-only LOTs (cart items not covered by BOM) cart.forEach(item => { - if (!item?.lot_name || coveredLots.has(item.lot_name)) return; + if (!item?.lot_name || coveredLots.has(U(item.lot_name))) return; _pushCartRow(item, true); - coveredLots.add(item.lot_name); + coveredLots.add(U(item.lot_name)); }); return { result, coveredLots };