fix: регистронезависимое сопоставление BOM↔корзина (фронт + CSV)
LOT из BOM-маппинга мог быть в смешанном регистре, а корзина — в каноничном UPPERCASE, из-за чего позиции дублировались (в таблице «Цена покупки» и в экспорте CSV). - localdb.NormalizeLotMappings: единая каноничная нормализация LOT-маппингов (UPPERCASE + схлопывание дублей с суммированием qty). Убраны две разошедшиеся копии normalizeLotMappings (handlers и services — последняя только тримила, что и было причиной бага в CSV). - export.go: BOM-ветка использует общую функцию + канонизирует LOT корзины для coverage/lookup. Удалена мёртвая computeMappingTotal. - index.html (renderPricingTab): сопоставление/дедуп LOT через каноничный ключ UPPERCASE; аксессоры _getRowBaseLot/_getRowAllocations возвращают канон. - Добавлен регресс-тест TestNormalizeLotMappings_*. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"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/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
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
|
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
||||||
}
|
}
|
||||||
// Persist canonical LOT mapping only.
|
// 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].ResolvedLotName = ""
|
||||||
body.VendorSpec[i].ResolutionSource = ""
|
body.VendorSpec[i].ResolutionSource = ""
|
||||||
body.VendorSpec[i].ManualLotSuggestion = ""
|
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.
|
// 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) {
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"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
|
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
||||||
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||||
items := make(LocalConfigItems, len(cfg.Items))
|
items := make(LocalConfigItems, len(cfg.Items))
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"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) {
|
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||||
item := &models.PricelistItem{
|
item := &models.PricelistItem{
|
||||||
LotName: "CPU_A",
|
LotName: "CPU_A",
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||||
coveredLots := make(map[string]struct{})
|
coveredLots := make(map[string]struct{})
|
||||||
for _, row := range localCfg.VendorSpec {
|
for _, row := range localCfg.VendorSpec {
|
||||||
rowMappings := normalizeLotMappings(row.LotMappings)
|
rowMappings := localdb.NormalizeLotMappings(row.LotMappings)
|
||||||
for _, mapping := range rowMappings {
|
for _, mapping := range rowMappings {
|
||||||
coveredLots[mapping.LotName] = struct{}{}
|
coveredLots[mapping.LotName] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
if item.LotName == "" {
|
lot := models.NormalizeLotName(item.LotName)
|
||||||
|
if lot == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := coveredLots[item.LotName]; ok {
|
if _, ok := coveredLots[lot]; ok {
|
||||||
continue
|
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{
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
LotDisplay: item.LotName,
|
LotDisplay: lot,
|
||||||
VendorPN: "—",
|
VendorPN: "—",
|
||||||
Description: componentDescriptions[item.LotName],
|
Description: componentDescriptions[lot],
|
||||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||||
Estimate: estimate,
|
Estimate: estimate,
|
||||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
|
||||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
@@ -665,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
|||||||
out := make([]string, 0)
|
out := make([]string, 0)
|
||||||
if includeBOM && localCfg != nil {
|
if includeBOM && localCfg != nil {
|
||||||
for _, row := range localCfg.VendorSpec {
|
for _, row := range localCfg.VendorSpec {
|
||||||
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) {
|
||||||
if _, ok := seen[mapping.LotName]; ok {
|
if _, ok := seen[mapping.LotName]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -688,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
|||||||
return out
|
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 {
|
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||||
if row.TotalPrice != nil {
|
if row.TotalPrice != nil {
|
||||||
return floatPtr(*row.TotalPrice)
|
return floatPtr(*row.TotalPrice)
|
||||||
@@ -720,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
|||||||
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
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
|
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||||
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||||
// the sum of ManualPrice values always equals manualPrice exactly.
|
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||||
|
|||||||
@@ -3379,8 +3379,9 @@ function setBOMManualLotDraft(rowIdx, value, el) {
|
|||||||
|
|
||||||
function _getRowAllocations(row) {
|
function _getRowAllocations(row) {
|
||||||
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
|
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
|
||||||
|
// Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
|
||||||
return list.map(a => ({
|
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)
|
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3389,9 +3390,11 @@ function _getRowLotQtyPerPN(row) {
|
|||||||
return (Number.isFinite(q) && q > 0) ? q : 1;
|
return (Number.isFinite(q) && q > 0) ? q : 1;
|
||||||
}
|
}
|
||||||
function _getRowBaseLot(row) {
|
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();
|
const manual = (row?.manual_lot || '').trim();
|
||||||
if (manual && _bomLotValid(manual)) return manual;
|
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
function _getRowCanonicalLotMappings(row) {
|
function _getRowCanonicalLotMappings(row) {
|
||||||
@@ -4039,40 +4042,39 @@ async function renderPricingTab() {
|
|||||||
const tfootSale = document.getElementById('pricing-foot-sale');
|
const tfootSale = document.getElementById('pricing-foot-sale');
|
||||||
|
|
||||||
const cart = window._currentCart || [];
|
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 = {};
|
const compMap = {};
|
||||||
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
|
(window._bomAllComponents || allComponents).forEach(c => { compMap[U(c.lot_name)] = c; });
|
||||||
const rowBaseLot = (row) => {
|
const rowBaseLot = (row) => _getRowBaseLot(row);
|
||||||
if (row?.resolved_lot) return row.resolved_lot;
|
|
||||||
if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
// 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.
|
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
||||||
const _cartQtyMap = {};
|
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 = [];
|
let itemsForPriceLevels = [];
|
||||||
if (bomRows.length) {
|
if (bomRows.length) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
bomRows.forEach(row => {
|
bomRows.forEach(row => {
|
||||||
const baseLot = rowBaseLot(row);
|
const baseLot = rowBaseLot(row);
|
||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot && !seen.has(baseLot)) {
|
if (baseLot && !seen.has(U(baseLot))) {
|
||||||
seen.add(baseLot);
|
seen.add(U(baseLot));
|
||||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[U(baseLot)] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
||||||
}
|
}
|
||||||
if (allocs.length) {
|
if (allocs.length) {
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
if (!seen.has(a.lot_name)) {
|
if (!seen.has(U(a.lot_name))) {
|
||||||
seen.add(a.lot_name);
|
seen.add(U(a.lot_name));
|
||||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
|
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (!item?.lot_name || seen.has(item.lot_name)) return;
|
if (!item?.lot_name || seen.has(U(item.lot_name))) return;
|
||||||
seen.add(item.lot_name);
|
seen.add(U(item.lot_name));
|
||||||
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
|
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -4097,7 +4099,7 @@ async function renderPricingTab() {
|
|||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
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 */ }
|
} catch(e) { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -4119,19 +4121,19 @@ async function renderPricingTab() {
|
|||||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
// ─── 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.
|
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||||
const cartQtyMap = {};
|
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 _buildRows = () => {
|
||||||
const result = [];
|
const result = [];
|
||||||
const coveredLots = new Set();
|
const coveredLots = new Set();
|
||||||
|
|
||||||
const _pushCartRow = (item, isEstOnly) => {
|
const _pushCartRow = (item, isEstOnly) => {
|
||||||
const pl = priceMap[item.lot_name];
|
const pl = priceMap[U(item.lot_name)];
|
||||||
const u = _getUnitPrices(pl);
|
const u = _getUnitPrices(pl);
|
||||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||||
result.push({
|
result.push({
|
||||||
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
||||||
vendorPN: null,
|
vendorPN: null,
|
||||||
desc: (compMap[item.lot_name] || {}).description || '',
|
desc: (compMap[U(item.lot_name)] || {}).description || '',
|
||||||
qty: item.quantity,
|
qty: item.quantity,
|
||||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
est: estUnit * item.quantity,
|
est: estUnit * item.quantity,
|
||||||
@@ -4148,28 +4150,28 @@ async function renderPricingTab() {
|
|||||||
const catB = ciStr(b.category);
|
const catB = ciStr(b.category);
|
||||||
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
|
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 };
|
return { result, coveredLots };
|
||||||
}
|
}
|
||||||
|
|
||||||
bomRows.forEach(row => {
|
bomRows.forEach(row => {
|
||||||
const baseLot = rowBaseLot(row);
|
const baseLot = rowBaseLot(row);
|
||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot) coveredLots.add(baseLot);
|
if (baseLot) coveredLots.add(U(baseLot));
|
||||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
allocs.forEach(a => coveredLots.add(U(a.lot_name)));
|
||||||
|
|
||||||
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
||||||
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
||||||
const vendorOrig = row.total_price != null ? row.total_price
|
const vendorOrig = row.total_price != null ? row.total_price
|
||||||
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
: (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
|
// Build per-LOT sub-rows
|
||||||
const subRows = [];
|
const subRows = [];
|
||||||
if (baseLot) {
|
if (baseLot) {
|
||||||
const u = _getUnitPrices(priceMap[baseLot]);
|
const u = _getUnitPrices(priceMap[U(baseLot)]);
|
||||||
const lotQty = _getRowLotQtyPerPN(row);
|
const lotQty = _getRowLotQtyPerPN(row);
|
||||||
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
|
const qty = cartQtyMap[U(baseLot)] ?? (row.quantity * lotQty);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4180,8 +4182,8 @@ async function renderPricingTab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
const u = _getUnitPrices(priceMap[U(a.lot_name)]);
|
||||||
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
|
const qty = cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4223,9 +4225,9 @@ async function renderPricingTab() {
|
|||||||
|
|
||||||
// Estimate-only LOTs (cart items not covered by BOM)
|
// Estimate-only LOTs (cart items not covered by BOM)
|
||||||
cart.forEach(item => {
|
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);
|
_pushCartRow(item, true);
|
||||||
coveredLots.add(item.lot_name);
|
coveredLots.add(U(item.lot_name));
|
||||||
});
|
});
|
||||||
|
|
||||||
return { result, coveredLots };
|
return { result, coveredLots };
|
||||||
|
|||||||
Reference in New Issue
Block a user