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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user