From 586114c79cd8a72f8d0c5c4125f15e5589d65dec Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 27 Feb 2026 09:47:46 +0300 Subject: [PATCH] refactor(bom): enforce canonical lot_mappings persistence --- internal/handlers/vendor_spec.go | 41 +++++++++++++++++++++ web/templates/index.html | 63 +++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 8bc145c..42bc9d4 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" @@ -69,6 +70,13 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { if body.VendorSpec[i].SortOrder == 0 { body.VendorSpec[i].SortOrder = (i + 1) * 10 } + // Persist canonical LOT mapping only. + body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings) + body.VendorSpec[i].ResolvedLotName = "" + body.VendorSpec[i].ResolutionSource = "" + body.VendorSpec[i].ManualLotSuggestion = "" + body.VendorSpec[i].LotQtyPerPN = 0 + body.VendorSpec[i].LotAllocations = nil } spec := localdb.VendorSpec(body.VendorSpec) @@ -85,6 +93,39 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) } +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 := strings.TrimSpace(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/web/templates/index.html b/web/templates/index.html index 2285a2d..d070764 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -230,11 +230,15 @@ -
+
+ + @@ -2889,20 +2893,6 @@ function removeBOMAllocation(rowIdx, allocIdx) { debouncedAutosaveBOM(); } -function toggleBOMBundle(rowIdx) { - const row = _findBOMRowBySource(rowIdx); - if (!row) return; - _ensureRowAllocations(row); - row.bundle_enabled = !row.bundle_enabled; - if (row.bundle_enabled && row.lot_allocations.length === 0) { - row.lot_allocations.push({ lot_name: row.resolved_lot || row.manual_lot || '', quantity: 1 }); - } - if (!row.bundle_enabled) { - row.lot_allocations = []; - } - renderBOMTable(); -} - function setBOMAllocationDraft(rowIdx, allocIdx, field, value, el) { const row = _findBOMRowBySource(rowIdx); if (!row) return; @@ -3659,6 +3649,7 @@ function setPricingCustomPriceFromVendor() { document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—'; document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : ''; + syncPricingLinkedInputs('price'); // Update discount info only const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row'); @@ -3674,12 +3665,48 @@ function setPricingCustomPriceFromVendor() { } } -function onPricingCustomPriceInput() { - const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; - +function getPricingEstimateTotal() { const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row'); let estimateTotal = 0; rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; }); + return estimateTotal; +} + +function syncPricingLinkedInputs(source) { + const customPriceInput = document.getElementById('pricing-custom-price'); + const upliftInput = document.getElementById('pricing-uplift'); + if (!customPriceInput || !upliftInput) return; + const estimateTotal = getPricingEstimateTotal(); + if (estimateTotal <= 0) { + upliftInput.value = ''; + return; + } + if (source === 'price') { + const customPrice = parseFloat(customPriceInput.value) || 0; + upliftInput.value = customPrice > 0 ? (customPrice / estimateTotal).toFixed(4) : ''; + return; + } + if (source === 'uplift') { + const uplift = parseFloat(upliftInput.value) || 0; + customPriceInput.value = uplift > 0 ? (estimateTotal * uplift).toFixed(2) : ''; + } +} + +function onPricingUpliftInput() { + syncPricingLinkedInputs('uplift'); + const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; + applyPricingCustomPrice(customPrice); +} + +function onPricingCustomPriceInput() { + syncPricingLinkedInputs('price'); + const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; + applyPricingCustomPrice(customPrice); +} + +function applyPricingCustomPrice(customPrice) { + const estimateTotal = getPricingEstimateTotal(); + const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row'); const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price'); const totalVendorEl = document.getElementById('pricing-total-vendor');