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');