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:
Mikhail Chusavitin
2026-03-17 23:53:32 +03:00
parent a8d8d7dfa9
commit 7de0f359b6
2 changed files with 139 additions and 55 deletions

View File

@@ -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));
});