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:
Mikhail Chusavitin
2026-07-24 17:01:31 +03:00
co-authored by Claude Opus 4.8
parent 0fa7b0b1b6
commit 905f9a4952
7 changed files with 547 additions and 119 deletions
+37
View File
@@ -7,3 +7,40 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Pricing footer totals: instant hover popup explaining how much of the sum rests on
world-pricelist stand-in prices. A native title= tooltip was too slow and too easy to
miss on a number the user is meant to distrust. */
.pricing-total-tip {
position: relative;
cursor: help;
}
.pricing-total-tip > .pricing-total-tip__body {
visibility: hidden;
opacity: 0;
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 30;
width: max-content;
max-width: 20rem;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
background: #1f2937;
color: #f9fafb;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.35;
text-align: left;
white-space: normal;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
transition: opacity 0.1s ease-in;
pointer-events: none;
}
.pricing-total-tip:hover > .pricing-total-tip__body,
.pricing-total-tip:focus-visible > .pricing-total-tip__body {
visibility: visible;
opacity: 1;
}
+165 -82
View File
@@ -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) {