Pricing tab: per-LOT row expansion with rowspan grouping
- Reorder columns: PN вендора / Описание / LOT / Кол-во / Estimate / Склад / Конкуренты / Ручная цена - Explode multi-LOT BOM rows into individual LOT sub-rows; PN вендора + Описание use rowspan to span the group - Rename "Своя цена" → "Ручная цена", "Проставить цены BOM" → "BOM Цена" - CSV export reads PN/Desc/LOT from data attributes to handle rowspan offset correctly - Document pricing tab layout contract in bible-local/02-architecture.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -211,9 +211,9 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
@@ -236,12 +236,12 @@
|
||||
</table>
|
||||
</div>
|
||||
<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-buy" step="0.01" min="0" placeholder="0.00"
|
||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onBuyCustomPriceInput()">
|
||||
<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 onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
@@ -260,9 +260,9 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
@@ -289,7 +289,7 @@
|
||||
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
|
||||
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onSaleMarkupInput()">
|
||||
<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-sale" step="0.01" min="0" placeholder="0.00"
|
||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onSaleCustomPriceInput()">
|
||||
@@ -3609,6 +3609,7 @@ 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 _buildRows = () => {
|
||||
const result = [];
|
||||
const coveredLots = new Set();
|
||||
@@ -3618,7 +3619,8 @@ async function renderPricingTab() {
|
||||
const u = _getUnitPrices(pl);
|
||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||
result.push({
|
||||
lotCell: escapeHtml(item.lot_name), vendorPN: null,
|
||||
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
||||
vendorPN: null,
|
||||
desc: (compMap[item.lot_name] || {}).description || '',
|
||||
qty: item.quantity,
|
||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||
@@ -3626,6 +3628,7 @@ async function renderPricingTab() {
|
||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
|
||||
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
|
||||
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
|
||||
groupStart: true, groupSize: 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3640,42 +3643,66 @@ async function renderPricingTab() {
|
||||
if (baseLot) coveredLots.add(baseLot);
|
||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
||||
|
||||
// Accumulate unit prices per 1 vendor PN (base + allocs)
|
||||
let rowEstUnit = 0, rowWhUnit = 0, rowCompUnit = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false;
|
||||
if (baseLot) {
|
||||
const u = _getUnitPrices(priceMap[baseLot]);
|
||||
const lotQty = _getRowLotQtyPerPN(row);
|
||||
if (u.estUnit > 0) { rowEstUnit += u.estUnit * lotQty; hasEst = true; }
|
||||
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * lotQty; hasWh = true; }
|
||||
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * lotQty; hasComp = true; }
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||
if (u.estUnit > 0) { rowEstUnit += u.estUnit * a.quantity; hasEst = true; }
|
||||
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * a.quantity; hasWh = true; }
|
||||
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * a.quantity; hasComp = true; }
|
||||
});
|
||||
|
||||
let lotCell = '<span class="text-red-500">н/д</span>';
|
||||
if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
|
||||
else if (baseLot) lotCell = escapeHtml(baseLot);
|
||||
else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
|
||||
|
||||
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 || '') : '');
|
||||
result.push({
|
||||
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
||||
estUnit: hasEst ? rowEstUnit : 0,
|
||||
warehouseUnit: hasWh ? rowWhUnit : null,
|
||||
competitorUnit: hasComp ? rowCompUnit : null,
|
||||
est: hasEst ? rowEstUnit * row.quantity : 0,
|
||||
warehouse: hasWh ? rowWhUnit * row.quantity : null,
|
||||
competitor: hasComp ? rowCompUnit * row.quantity : null,
|
||||
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||
|
||||
// Build per-LOT sub-rows
|
||||
const subRows = [];
|
||||
if (baseLot) {
|
||||
const u = _getUnitPrices(priceMap[baseLot]);
|
||||
const lotQty = _getRowLotQtyPerPN(row);
|
||||
const qty = row.quantity * lotQty;
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||
est: u.estUnit > 0 ? u.estUnit * qty : 0,
|
||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
|
||||
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
|
||||
});
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||
const qty = row.quantity * a.quantity;
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||
est: u.estUnit > 0 ? u.estUnit * qty : 0,
|
||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
|
||||
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
|
||||
});
|
||||
});
|
||||
|
||||
if (!subRows.length) {
|
||||
result.push({
|
||||
lotCell: '<span class="text-red-500">н/д</span>', lotText: '',
|
||||
vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
||||
estUnit: 0, warehouseUnit: null, competitorUnit: null,
|
||||
est: 0, warehouse: null, competitor: null,
|
||||
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||
groupStart: true, groupSize: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSize = subRows.length;
|
||||
subRows.forEach((sub, idx) => {
|
||||
result.push({
|
||||
lotCell: sub.lotCell, lotText: sub.lotText,
|
||||
vendorPN: row.vendor_pn, desc,
|
||||
qty: sub.qty,
|
||||
estUnit: sub.estUnit, warehouseUnit: sub.warehouseUnit, competitorUnit: sub.competitorUnit,
|
||||
est: sub.est, warehouse: sub.warehouse, competitor: sub.competitor,
|
||||
vendorOrig: idx === 0 ? vendorOrig : null,
|
||||
vendorOrigUnit: idx === 0 ? vendorOrigUnit : null,
|
||||
isEstOnly: false,
|
||||
groupStart: idx === 0,
|
||||
groupSize: idx === 0 ? groupSize : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3708,19 +3735,28 @@ async function renderPricingTab() {
|
||||
tr.dataset.qty = r.qty;
|
||||
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
|
||||
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||
tr.dataset.vendorPn = r.vendorPN || '';
|
||||
tr.dataset.desc = r.desc;
|
||||
tr.dataset.lot = r.lotText;
|
||||
if (r.est > 0) { totEst += r.est; hasEst = true; }
|
||||
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
||||
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
||||
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
|
||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||
const pnDescHtml = r.groupStart ? (() => {
|
||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||
})() : '';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
||||
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
|
||||
${pnDescHtml}
|
||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
|
||||
`;
|
||||
tbodyBuy.appendChild(tr);
|
||||
});
|
||||
@@ -3752,18 +3788,27 @@ async function renderPricingTab() {
|
||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
||||
tr.dataset.estSale = saleEstTotal;
|
||||
tr.dataset.qty = r.qty;
|
||||
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||
tr.dataset.vendorPn = r.vendorPN || '';
|
||||
tr.dataset.desc = r.desc;
|
||||
tr.dataset.lot = r.lotText;
|
||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
|
||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
|
||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||
const pnDescHtml = r.groupStart ? (() => {
|
||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||
})() : '';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
||||
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale text-gray-400">—</td>
|
||||
${pnDescHtml}
|
||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
||||
`;
|
||||
tbodySale.appendChild(tr);
|
||||
});
|
||||
@@ -3961,12 +4006,25 @@ function exportPricingCSV(table) {
|
||||
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const lines = [headers.map(csvEscape).join(csvDelimiter)];
|
||||
|
||||
rows.forEach(tr => {
|
||||
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
|
||||
const pn = cleanExportCell(tr.dataset.vendorPn || '');
|
||||
const desc = cleanExportCell(tr.dataset.desc || '');
|
||||
const lot = cleanExportCell(tr.dataset.lot || '');
|
||||
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
|
||||
const isGroupStart = tr.dataset.groupStart === 'true';
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cleanExportCell(cells[i].textContent) : '');
|
||||
const o = isGroupStart ? 2 : 0;
|
||||
const cols = [pn, desc, lot,
|
||||
cleanExportCell(cells[o]?.textContent),
|
||||
cleanExportCell(cells[o+1]?.textContent),
|
||||
cleanExportCell(cells[o+2]?.textContent),
|
||||
cleanExportCell(cells[o+3]?.textContent),
|
||||
cleanExportCell(cells[o+4]?.textContent),
|
||||
];
|
||||
lines.push(cols.map(csvEscape).join(csvDelimiter));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user