refactor(bom): enforce canonical lot_mappings persistence
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
@@ -69,6 +70,13 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
if body.VendorSpec[i].SortOrder == 0 {
|
if body.VendorSpec[i].SortOrder == 0 {
|
||||||
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
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)
|
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})
|
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.
|
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
||||||
// POST /api/configs/:uuid/vendor-spec/resolve
|
// POST /api/configs/:uuid/vendor-spec/resolve
|
||||||
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||||
|
|||||||
@@ -230,11 +230,15 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-center gap-4">
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
||||||
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
|
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
|
||||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
oninput="onPricingCustomPriceInput()">
|
oninput="onPricingCustomPriceInput()">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Uplift:</label>
|
||||||
|
<input type="number" id="pricing-uplift" step="0.0001" min="0" placeholder="1.0000"
|
||||||
|
class="w-32 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
oninput="onPricingUpliftInput()">
|
||||||
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
||||||
Проставить цены BOM
|
Проставить цены BOM
|
||||||
</button>
|
</button>
|
||||||
@@ -2889,20 +2893,6 @@ function removeBOMAllocation(rowIdx, allocIdx) {
|
|||||||
debouncedAutosaveBOM();
|
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) {
|
function setBOMAllocationDraft(rowIdx, allocIdx, field, value, el) {
|
||||||
const row = _findBOMRowBySource(rowIdx);
|
const row = _findBOMRowBySource(rowIdx);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -3659,6 +3649,7 @@ function setPricingCustomPriceFromVendor() {
|
|||||||
|
|
||||||
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
||||||
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
||||||
|
syncPricingLinkedInputs('price');
|
||||||
|
|
||||||
// Update discount info only
|
// Update discount info only
|
||||||
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||||
@@ -3674,12 +3665,48 @@ function setPricingCustomPriceFromVendor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPricingCustomPriceInput() {
|
function getPricingEstimateTotal() {
|
||||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
|
||||||
|
|
||||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||||
let estimateTotal = 0;
|
let estimateTotal = 0;
|
||||||
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 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 vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||||
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
||||||
|
|||||||
Reference in New Issue
Block a user