feat: PN code в импорте BOM, объединённый список LOT (world ∪ estimate)
Колонка «PN code» в таблице импорта BOM: HPE-спецификации хранят партномер в двух столбцах, канонический вид — P52534-B21#B19. Склейка попадает в vendor_partnumber и сравнивается с книгой партномеров как есть; при загрузке разбирается обратно на две колонки. Схему БД менять не пришлось. Список допустимых LOT собирается из world ∪ estimate (componentUniverse), а не только из estimate. Прежняя область видимости молча теряла lot_mappings: резолвер сопоставлял PN по книге, которая про прайслисты не знает, а фронт отбрасывал LOT, которого нет в estimate — при этом в корзину он всё равно попадал. World-only позиции отдают price_quality 0. Кнопка «Пересопоставить» — перерешать BOM по актуальной книге, так как при открытии конфигурации сопоставления остаются замороженными. Итоги на вкладке «Ценообразование»: звёздочки убраны (переносились на новую строку), все три суммы красные, при наведении — попап с долей цен из прайслиста WORLD. Колонка «PN вендора» больше не переносится. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0fa7b0b1b6
commit
905f9a4952
+165
-82
@@ -194,6 +194,9 @@
|
||||
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
||||
Очистить
|
||||
</button>
|
||||
<button onclick="reresolveBOM()" title="Заново сопоставить строки BOM по актуальной книге партномеров" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
||||
Пересопоставить
|
||||
</button>
|
||||
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Сохранить BOM
|
||||
</button>
|
||||
@@ -219,26 +222,26 @@
|
||||
<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">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>
|
||||
<th class="px-3 py-2 text-right border-b">Конкуренты</th>
|
||||
<th class="px-3 py-2 text-right border-b">Ручная цена</th>
|
||||
<th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
|
||||
<th class="px-2 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-2 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-2 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-2 py-2 text-right border-b">Склад</th>
|
||||
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
|
||||
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body-buy">
|
||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-competitor">—</td>
|
||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor">—</td>
|
||||
<td colspan="4" class="px-2 py-2 text-right">Итого:</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-buy-estimate">—</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-buy-warehouse">—</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-buy-competitor">—</td>
|
||||
<td class="px-2 py-2 text-right font-bold" id="pricing-total-buy-vendor">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -268,26 +271,26 @@
|
||||
<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">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>
|
||||
<th class="px-3 py-2 text-right border-b">Конкуренты</th>
|
||||
<th class="px-3 py-2 text-right border-b">Ручная цена</th>
|
||||
<th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
|
||||
<th class="px-2 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-2 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-2 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-2 py-2 text-right border-b">Склад</th>
|
||||
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
|
||||
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body-sale">
|
||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-competitor">—</td>
|
||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-sale-vendor">—</td>
|
||||
<td colspan="4" class="px-2 py-2 text-right">Итого:</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-sale-estimate">—</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-sale-warehouse">—</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-sale-competitor">—</td>
|
||||
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-vendor">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -3480,6 +3483,7 @@ let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRow
|
||||
const BOM_COL_TYPES = [
|
||||
{ value: 'ignore', label: 'Не использовать' },
|
||||
{ value: 'pn', label: 'P/N' },
|
||||
{ value: 'pn_code', label: 'PN code' },
|
||||
{ value: 'qty', label: 'Кол-во' },
|
||||
{ value: 'price', label: 'Цена' },
|
||||
{ value: 'description', label: 'Описание' }
|
||||
@@ -3490,6 +3494,7 @@ function _bomRawHeaderWidthClass(colType) {
|
||||
case 'qty': return 'w-24 min-w-24';
|
||||
case 'price': return 'w-32 min-w-32';
|
||||
case 'pn': return 'min-w-40';
|
||||
case 'pn_code': return 'w-28 min-w-28';
|
||||
case 'description': return 'min-w-48';
|
||||
default: return 'min-w-28';
|
||||
}
|
||||
@@ -3499,10 +3504,32 @@ function _bomRawCellWidthClass(colType) {
|
||||
switch (colType) {
|
||||
case 'qty': return 'w-24 min-w-24';
|
||||
case 'price': return 'w-32 min-w-32';
|
||||
case 'pn_code': return 'w-28 min-w-28';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// HPE specs split the partnumber across two columns (PN + option CODE): the
|
||||
// canonical partnumber is "P52534-B21#B19". A row with an empty code column
|
||||
// keeps the bare PN.
|
||||
function _bomComposePN(cols, idx) {
|
||||
const pn = ((cols[idx.pn[0]] || '') + '').trim();
|
||||
if (!pn) return '';
|
||||
const codeCol = idx.pn_code.length ? idx.pn_code[0] : -1;
|
||||
if (codeCol === -1) return pn;
|
||||
const code = ((cols[codeCol] || '') + '').trim();
|
||||
return code ? `${pn}#${code}` : pn;
|
||||
}
|
||||
|
||||
// Inverse of _bomComposePN: splits a stored partnumber back into [pn, code].
|
||||
// Only the first '#' separates the option code, so a code containing '#' round-trips.
|
||||
function _bomSplitPN(vendorPN) {
|
||||
const s = (vendorPN || '').trim();
|
||||
const at = s.indexOf('#');
|
||||
if (at === -1) return [s, ''];
|
||||
return [s.slice(0, at), s.slice(at + 1)];
|
||||
}
|
||||
|
||||
function parsePastePrice(s) {
|
||||
if (!s) return null;
|
||||
let v = String(s).replace(/[$\s]/g, '');
|
||||
@@ -3649,7 +3676,7 @@ async function handleBOMPaste(event) {
|
||||
|
||||
function _getBomColumnTypeIndexes() {
|
||||
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
||||
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
||||
const idx = { ignore: [], pn: [], pn_code: [], qty: [], price: [], description: [] };
|
||||
bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
|
||||
return idx;
|
||||
}
|
||||
@@ -3659,6 +3686,7 @@ function _validateBomColumnTypes() {
|
||||
const idx = _getBomColumnTypeIndexes();
|
||||
if (!idx) return { ok: false, message: 'Нет данных BOM' };
|
||||
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' };
|
||||
if (idx.pn_code.length > 1) return { ok: false, message: 'Можно выбрать только один столбец PN code.' };
|
||||
if (idx.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
|
||||
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
|
||||
if (idx.description.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Описание.' };
|
||||
@@ -3801,10 +3829,20 @@ function _getRowBaseLot(row) {
|
||||
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
|
||||
return '';
|
||||
}
|
||||
// A LOT resolved from the partnumber book is authoritative even when it is absent
|
||||
// from the component list: that list is the active estimate pricelist only
|
||||
// (localdb.ListComponents), so a LOT the pricelist does not carry would otherwise be
|
||||
// dropped from lot_mappings on save while still reaching the cart via _getRowBaseLot.
|
||||
// Hand-typed LOTs stay validated.
|
||||
function _bomLotPersistable(row, lot) {
|
||||
if (!lot) return false;
|
||||
return row?.resolution_source === 'book' || _bomLotValid(lot);
|
||||
}
|
||||
|
||||
function _getRowCanonicalLotMappings(row) {
|
||||
const out = [];
|
||||
const baseLot = _getRowBaseLot(row);
|
||||
if (baseLot && _bomLotValid(baseLot)) {
|
||||
if (baseLot && _bomLotPersistable(row, baseLot)) {
|
||||
out.push({
|
||||
lot_name: baseLot,
|
||||
quantity_per_pn: _getRowLotQtyPerPN(row)
|
||||
@@ -3980,7 +4018,6 @@ function rebuildBOMRowsFromRaw() {
|
||||
_setBomUIError('');
|
||||
|
||||
const idx = validation.idx;
|
||||
const pnCol = idx.pn[0];
|
||||
const qtyCol = idx.qty[0];
|
||||
const priceCol = idx.price.length ? idx.price[0] : -1;
|
||||
const descCol = idx.description.length ? idx.description[0] : -1;
|
||||
@@ -3988,7 +4025,7 @@ function rebuildBOMRowsFromRaw() {
|
||||
const nextRows = [];
|
||||
bomImportRaw.rows.forEach((cols, rowIdx) => {
|
||||
if (bomImportRaw.ignoredRows?.[rowIdx]) return;
|
||||
const pn = ((cols[pnCol] || '') + '').trim();
|
||||
const pn = _bomComposePN(cols, idx);
|
||||
if (!pn) return;
|
||||
const qtyRaw = ((cols[qtyCol] || '') + '').trim();
|
||||
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
|
||||
@@ -4067,13 +4104,12 @@ async function resolveBOM() {
|
||||
if (bomImportRaw && bomImportRaw.mode === 'raw') {
|
||||
const validation = _validateBomColumnTypes();
|
||||
if (validation.ok) {
|
||||
const pnCol = validation.idx.pn[0];
|
||||
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
|
||||
ignoredSeen = (bomImportRaw.rows || [])
|
||||
.map((cols, rowIdx) => ({ cols, rowIdx }))
|
||||
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
|
||||
.map(({ cols }) => ({
|
||||
partnumber: ((cols[pnCol] || '') + '').trim(),
|
||||
partnumber: _bomComposePN(cols, validation.idx),
|
||||
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
|
||||
ignored: true
|
||||
}))
|
||||
@@ -4245,6 +4281,21 @@ function renderBOMTable() {
|
||||
if (currentTopTab === 'pricing') renderPricingTab();
|
||||
}
|
||||
|
||||
// Manual re-resolve against the current partnumber book. loadVendorSpec deliberately
|
||||
// does not re-resolve on open — a configuration keeps the mappings frozen at save time —
|
||||
// so this is the way to pick up book entries added since. Book matches win over the
|
||||
// stored mapping (resolver step 1 > step 2); rows the book does not know keep theirs.
|
||||
async function reresolveBOM() {
|
||||
if (!bomRows.length) return;
|
||||
const before = bomRows.map(r => _getRowBaseLot(r));
|
||||
await resolveBOM();
|
||||
const changed = bomRows.reduce((n, r, i) => n + (_getRowBaseLot(r) !== before[i] ? 1 : 0), 0);
|
||||
const stillUnresolved = bomRows.filter(r => !_getRowBaseLot(r)).length;
|
||||
showToast(changed
|
||||
? `Пересопоставлено строк: ${changed}. Без LOT: ${stillUnresolved}`
|
||||
: `Изменений нет. Без LOT: ${stillUnresolved}`, changed ? 'success' : 'info');
|
||||
}
|
||||
|
||||
let _resolveBOMTimer = null;
|
||||
function debouncedResolveBOM() {
|
||||
clearTimeout(_resolveBOMTimer);
|
||||
@@ -4416,16 +4467,22 @@ async function loadVendorSpec(configUUID) {
|
||||
});
|
||||
|
||||
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste).
|
||||
// Columns: Qty | P/N | Description | Price
|
||||
// Columns: Qty | P/N | Description | Price, plus PN code when any saved
|
||||
// partnumber carries an HPE option code ("P52534-B21#B19"). The column layout
|
||||
// itself is not persisted, so it is re-derived from the stored partnumbers.
|
||||
const hasPNCode = bomRows.some(r => (r.vendor_pn || '').includes('#'));
|
||||
bomImportRaw = {
|
||||
mode: 'raw',
|
||||
rows: bomRows.map(r => ([
|
||||
String(r.quantity ?? 1),
|
||||
r.vendor_pn || '',
|
||||
r.description || '',
|
||||
r.unit_price != null ? String(r.unit_price) : ''
|
||||
])),
|
||||
columnTypes: ['qty', 'pn', 'description', 'price'],
|
||||
rows: bomRows.map(r => {
|
||||
const [pn, code] = _bomSplitPN(r.vendor_pn || '');
|
||||
const cells = [String(r.quantity ?? 1), pn];
|
||||
if (hasPNCode) cells.push(code);
|
||||
cells.push(r.description || '', r.unit_price != null ? String(r.unit_price) : '');
|
||||
return cells;
|
||||
}),
|
||||
columnTypes: hasPNCode
|
||||
? ['qty', 'pn', 'pn_code', 'description', 'price']
|
||||
: ['qty', 'pn', 'description', 'price'],
|
||||
ignoredRows: {},
|
||||
rowErrors: {},
|
||||
uiError: ''
|
||||
@@ -4659,12 +4716,13 @@ async function renderPricingTab() {
|
||||
// ─── Populate Buy table ──────────────────────────────────────────────────
|
||||
tbodyBuy.innerHTML = '';
|
||||
if (!rowData.length) {
|
||||
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tfootBuy.classList.add('hidden');
|
||||
} else {
|
||||
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
|
||||
let cntWh = 0, cntComp = 0;
|
||||
let worldEst = 0, worldWh = 0, worldComp = 0;
|
||||
rowData.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row-buy');
|
||||
@@ -4681,43 +4739,44 @@ async function renderPricingTab() {
|
||||
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.est > 0) { totEst += r.est; hasEst = true; if (r.estWorld) worldEst += r.est; }
|
||||
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; if (r.whWorld) worldWh += r.warehouse; }
|
||||
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; if (r.compWorld) worldComp += r.competitor; }
|
||||
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>`;
|
||||
return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td${rs} class="px-2 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 = `
|
||||
${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.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${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>
|
||||
<td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
|
||||
<td class="px-2 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);
|
||||
});
|
||||
document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
|
||||
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
|
||||
_setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length);
|
||||
_setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, rowData.length);
|
||||
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
|
||||
_setPricingTotal('pricing-total-buy-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
|
||||
_setPricingTotal('pricing-total-buy-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
|
||||
_setPricingTotal('pricing-total-buy-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
|
||||
tfootBuy.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ─── Populate Sale table ─────────────────────────────────────────────────
|
||||
tbodySale.innerHTML = '';
|
||||
if (!rowData.length) {
|
||||
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tbodySale.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tfootSale.classList.add('hidden');
|
||||
} else {
|
||||
let totEst = 0, totWh = 0, totComp = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false;
|
||||
let cntWh = 0, cntComp = 0;
|
||||
let worldEst = 0, worldWh = 0, worldComp = 0;
|
||||
rowData.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row-sale');
|
||||
@@ -4738,30 +4797,30 @@ async function renderPricingTab() {
|
||||
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++; }
|
||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
|
||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; }
|
||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; }
|
||||
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>`;
|
||||
return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||
<td${rs} class="px-2 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 = `
|
||||
${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.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${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>
|
||||
<td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
||||
`;
|
||||
tbodySale.appendChild(tr);
|
||||
});
|
||||
document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
|
||||
document.getElementById('pricing-total-sale-vendor').textContent = '—';
|
||||
_setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length);
|
||||
_setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, rowData.length);
|
||||
document.getElementById('pricing-total-sale-vendor').textContent = '—';
|
||||
_setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
|
||||
_setPricingTotal('pricing-total-sale-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
|
||||
_setPricingTotal('pricing-total-sale-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
|
||||
tfootSale.classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -4772,17 +4831,41 @@ async function renderPricingTab() {
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
|
||||
function _setPartialTotal(elId, has, total, count, totalRows) {
|
||||
// Sets a footer total cell. The total is always red — it is a sum the user must not read
|
||||
// as a firm number — and hovering it pops up how much of that sum rests on world-pricelist
|
||||
// stand-in prices (see decisions/2026-07-10-world-pricelist-fallback.md), plus the row
|
||||
// coverage when some positions have no price at all. The popup is a styled element rather
|
||||
// than title=, which appears too late to notice. No asterisk: it wrapped onto its own line
|
||||
// and pushed the footer around.
|
||||
function _setPricingTotal(elId, has, total, worldTotal, count, totalRows) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
|
||||
if (!has) { el.textContent = '—'; return; }
|
||||
if (count < totalRows) {
|
||||
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`;
|
||||
} else {
|
||||
el.textContent = formatCurrency(total);
|
||||
el.className = el.className
|
||||
.replace(/\btext-red-\d+\b/g, '')
|
||||
.replace(/\bpricing-total-tip\b/g, '')
|
||||
.trim();
|
||||
if (!has) {
|
||||
el.textContent = '—';
|
||||
return;
|
||||
}
|
||||
|
||||
const share = total > 0 ? (worldTotal / total) * 100 : 0;
|
||||
const parts = [
|
||||
share > 0
|
||||
? `<b>${formatPercent(share)}%</b> от этой суммы посчитано по ценам прайслиста WORLD — это заглушка, а не реальная цена`
|
||||
: 'Цены прайслиста WORLD в этой сумме не использованы'
|
||||
];
|
||||
if (count < totalRows) {
|
||||
parts.push(`Цены есть не для всех позиций: ${count} из ${totalRows}`);
|
||||
}
|
||||
|
||||
el.className = `${el.className} text-red-600 pricing-total-tip`.trim();
|
||||
el.innerHTML = `${formatCurrency(total)}<span class="pricing-total-tip__body">${parts.join('<br>')}</span>`;
|
||||
}
|
||||
|
||||
// One decimal, comma separator, no trailing ",0".
|
||||
function formatPercent(v) {
|
||||
return v.toFixed(1).replace(/\.0$/, '').replace('.', ',');
|
||||
}
|
||||
|
||||
function parseDecimalInput(raw) {
|
||||
|
||||
Reference in New Issue
Block a user