feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)
- Migration 029: local_partnumber_books, local_partnumber_book_items, vendor_spec TEXT column on local_configurations - Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec, VendorSpecItem with JSON Valuer/Scanner - Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber, SaveBook/Items, ListBooks, CountBookItems) - Service: VendorSpecResolver 3-step resolution (book → manual suggestion → unresolved) + AggregateLOTs with is_primary_pn qty logic - Sync: PullPartnumberBooks append-only pull from qt_partnumber_books - Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler - Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books, /api/sync/partnumber-books, /partnumber-books page - UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste, PN resolution, inline LOT autocomplete, pricing table - Bible: 03-database.md updated, 09-vendor-spec.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-level tabs: Estimate | BOM вендора | Ценообразование -->
|
||||
<div class="bg-white rounded-lg shadow mb-0">
|
||||
<nav class="flex border-b">
|
||||
<button id="top-tab-estimate" onclick="switchTopTab('estimate')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-blue-600 text-blue-600">
|
||||
Estimate
|
||||
</button>
|
||||
<button id="top-tab-bom" onclick="switchTopTab('bom')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
BOM вендора
|
||||
</button>
|
||||
<button id="top-tab-pricing" onclick="switchTopTab('pricing')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
Ценообразование
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Top-tab section: Estimate -->
|
||||
<div id="top-section-estimate">
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="border-b">
|
||||
@@ -229,6 +250,103 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end top-section-estimate -->
|
||||
|
||||
<!-- Top-tab section: BOM вендора -->
|
||||
<div id="top-section-bom" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">Вставьте таблицу из Excel (Ctrl+V в область ниже):</span>
|
||||
</div>
|
||||
<div id="bom-paste-area"
|
||||
contenteditable="true"
|
||||
tabindex="0"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 min-h-16 text-gray-400 focus:outline-none focus:border-blue-400 cursor-text mb-4"
|
||||
onpaste="handleBOMPaste(event)"
|
||||
placeholder="Нажмите сюда и вставьте из Excel (Ctrl+V)...">
|
||||
Нажмите сюда и вставьте из Excel (Ctrl+V)...
|
||||
</div>
|
||||
|
||||
<!-- BOM table -->
|
||||
<div id="bom-table-container" class="hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<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-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-left 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-3 py-2 text-left border-b">LOT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bom-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between text-sm text-gray-600">
|
||||
<div id="bom-stats"></div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Сохранить BOM
|
||||
</button>
|
||||
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
|
||||
Пересчитать эстимейт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end top-section-bom -->
|
||||
|
||||
<!-- Top-tab section: Ценообразование -->
|
||||
<div id="top-section-pricing" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div id="pricing-table-container">
|
||||
<div class="overflow-x-auto">
|
||||
<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">LOT</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">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
<th class="px-3 py-2 text-right border-b">Конк.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-table-body">
|
||||
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="3" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-vendor">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
||||
<input type="number" id="pricing-custom-price" 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="onPricingCustomPriceInput()">
|
||||
<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">
|
||||
= Сумма цен вендора
|
||||
</button>
|
||||
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
|
||||
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end top-section-pricing -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Price settings modal -->
|
||||
@@ -787,6 +905,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Load vendor spec BOM for this configuration
|
||||
if (configUUID) {
|
||||
loadVendorSpec(configUUID);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAllComponents() {
|
||||
@@ -1775,6 +1898,7 @@ function removeFromCart(lotName) {
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
|
||||
@@ -2486,6 +2610,415 @@ function updatePriceUpdateDate(dateStr) {
|
||||
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
|
||||
}
|
||||
|
||||
// ==================== TOP-LEVEL TABS ====================
|
||||
|
||||
let currentTopTab = 'estimate';
|
||||
|
||||
function switchTopTab(tab) {
|
||||
currentTopTab = tab;
|
||||
const tabs = ['estimate', 'bom', 'pricing'];
|
||||
tabs.forEach(t => {
|
||||
const btn = document.getElementById('top-tab-' + t);
|
||||
const section = document.getElementById('top-section-' + t);
|
||||
if (t === tab) {
|
||||
btn.classList.remove('border-transparent', 'text-gray-500');
|
||||
btn.classList.add('border-blue-600', 'text-blue-600');
|
||||
section.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||||
btn.classList.add('border-transparent', 'text-gray-500');
|
||||
section.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
if (tab === 'pricing') {
|
||||
renderPricingTab();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BOM ВЕНДОРА ====================
|
||||
|
||||
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
|
||||
|
||||
function handleBOMPaste(event) {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
if (!text) return;
|
||||
|
||||
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
|
||||
const parsed = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const cols = lines[i].split('\t');
|
||||
if (cols.length < 2) continue;
|
||||
|
||||
// Auto-detect columns:
|
||||
// 2 cols → [PN, qty]
|
||||
// 3+ cols → first text=PN, first numeric=qty, subsequent numeric=prices
|
||||
let pn = '', qty = 0, description = '', unit_price = null, total_price = null;
|
||||
|
||||
if (cols.length === 2) {
|
||||
pn = cols[0].trim();
|
||||
qty = parseInt(cols[1]) || 0;
|
||||
// Skip header row if qty is NaN
|
||||
if (!qty && i === 0) continue;
|
||||
} else {
|
||||
pn = cols[0].trim();
|
||||
// Find first numeric column for qty
|
||||
let qtyIdx = -1, priceIdx = -1;
|
||||
for (let c = 1; c < cols.length; c++) {
|
||||
const v = cols[c].trim().replace(/[, ]/g, '');
|
||||
if (!isNaN(parseFloat(v)) && v !== '') {
|
||||
if (qtyIdx === -1) { qtyIdx = c; }
|
||||
else if (priceIdx === -1) { priceIdx = c; }
|
||||
}
|
||||
}
|
||||
// If first row has non-numeric qty → likely header, skip
|
||||
if (qtyIdx === -1) continue;
|
||||
const rawQty = cols[qtyIdx].trim().replace(/[, ]/g, '');
|
||||
if (i === 0 && isNaN(parseInt(rawQty))) continue;
|
||||
qty = parseInt(rawQty) || 1;
|
||||
|
||||
// Description: columns between PN and first numeric
|
||||
const descParts = [];
|
||||
for (let c = 1; c < qtyIdx; c++) { descParts.push(cols[c].trim()); }
|
||||
description = descParts.join(' ').trim();
|
||||
|
||||
if (priceIdx !== -1) {
|
||||
const rawPrice = cols[priceIdx].trim().replace(/[, ]/g, '');
|
||||
unit_price = parseFloat(rawPrice) || null;
|
||||
if (unit_price && qty) total_price = unit_price * qty;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pn) continue;
|
||||
parsed.push({
|
||||
sort_order: (parsed.length + 1) * 10,
|
||||
vendor_pn: pn,
|
||||
quantity: qty || 1,
|
||||
description,
|
||||
unit_price,
|
||||
total_price,
|
||||
resolved_lot: '',
|
||||
resolution_source: 'unresolved',
|
||||
manual_lot: ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed.length) {
|
||||
alert('Не удалось распознать данные. Убедитесь, что скопированы строки из Excel.');
|
||||
return;
|
||||
}
|
||||
|
||||
bomRows = parsed;
|
||||
resolveBOM();
|
||||
}
|
||||
|
||||
async function resolveBOM() {
|
||||
if (!configUUID) return;
|
||||
const specPayload = bomRows.map(r => ({
|
||||
sort_order: r.sort_order,
|
||||
vendor_partnumber: r.vendor_pn,
|
||||
quantity: r.quantity,
|
||||
description: r.description,
|
||||
unit_price: r.unit_price,
|
||||
total_price: r.total_price,
|
||||
manual_lot_suggestion: r.manual_lot
|
||||
}));
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/resolve`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({vendor_spec: specPayload})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const data = await resp.json();
|
||||
// Merge resolution results back into bomRows
|
||||
if (data.resolved) {
|
||||
data.resolved.forEach((r, i) => {
|
||||
if (bomRows[i]) {
|
||||
bomRows[i].resolved_lot = r.resolved_lot_name || '';
|
||||
bomRows[i].resolution_source = r.resolution_source || 'unresolved';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Resolution failed:', e);
|
||||
}
|
||||
|
||||
renderBOMTable();
|
||||
}
|
||||
|
||||
function renderBOMTable() {
|
||||
const tbody = document.getElementById('bom-table-body');
|
||||
const cart = window._currentCart || [];
|
||||
|
||||
// Build cart map: lot_name → quantity
|
||||
const cartMap = {};
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
|
||||
|
||||
let unresolved = 0, mismatches = 0;
|
||||
tbody.innerHTML = '';
|
||||
|
||||
bomRows.forEach((row, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
|
||||
const notInCart = row.resolved_lot && cartQty === null;
|
||||
|
||||
if (isUnresolved) unresolved++;
|
||||
if (qtyMismatch || notInCart) mismatches++;
|
||||
|
||||
let rowClass = '';
|
||||
if (isUnresolved) rowClass = 'bg-red-50';
|
||||
else if (qtyMismatch) rowClass = 'bg-yellow-50';
|
||||
else if (notInCart) rowClass = 'bg-orange-50';
|
||||
|
||||
tr.className = rowClass;
|
||||
|
||||
let lotCell = '';
|
||||
if (isUnresolved) {
|
||||
lotCell = `<input type="text" placeholder="Введите LOT..." value="${row.manual_lot || ''}"
|
||||
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
|
||||
oninput="bomRows[${idx}].manual_lot = this.value; debouncedResolveBOM();"
|
||||
list="lot-autocomplete-list">`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
|
||||
else if (notInCart) suffix = ` <span class="text-orange-500 text-xs">новый</span>`;
|
||||
lotCell = `<span class="font-mono text-xs">${row.resolved_lot}</span>${suffix}`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-gray-600 text-xs">${escapeHtml(row.description || '')}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${row.unit_price != null ? formatCurrency(row.unit_price) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${row.total_price != null ? formatCurrency(row.total_price) : '—'}</td>
|
||||
<td class="px-3 py-1.5">${lotCell}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Stats
|
||||
const statsEl = document.getElementById('bom-stats');
|
||||
statsEl.textContent = `Строк: ${bomRows.length} | Не сопоставлено: ${unresolved} | Расхождений: ${mismatches}`;
|
||||
|
||||
document.getElementById('bom-table-container').classList.remove('hidden');
|
||||
|
||||
// Also update pricing tab if visible
|
||||
if (currentTopTab === 'pricing') renderPricingTab();
|
||||
}
|
||||
|
||||
let _resolveBOMTimer = null;
|
||||
function debouncedResolveBOM() {
|
||||
clearTimeout(_resolveBOMTimer);
|
||||
_resolveBOMTimer = setTimeout(() => resolveBOM(), 500);
|
||||
}
|
||||
|
||||
async function saveBOM() {
|
||||
if (!configUUID || !bomRows.length) return;
|
||||
const spec = bomRows.map(r => ({
|
||||
sort_order: r.sort_order,
|
||||
vendor_partnumber: r.vendor_pn,
|
||||
quantity: r.quantity,
|
||||
description: r.description,
|
||||
unit_price: r.unit_price,
|
||||
total_price: r.total_price,
|
||||
resolved_lot_name: r.resolved_lot,
|
||||
resolution_source: r.resolution_source,
|
||||
manual_lot_suggestion: r.manual_lot || null
|
||||
}));
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({vendor_spec: spec})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showToast('BOM сохранён', 'success');
|
||||
} catch (e) {
|
||||
showToast('Ошибка сохранения BOM: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBOMToEstimate() {
|
||||
if (!bomRows.length) return;
|
||||
const resolved = bomRows.filter(r => r.resolved_lot);
|
||||
if (!resolved.length) {
|
||||
alert('Нет сопоставленных строк. Сначала настройте LOT для всех позиций.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate quantities
|
||||
const lotMap = {};
|
||||
resolved.forEach(r => {
|
||||
if (!lotMap[r.resolved_lot]) lotMap[r.resolved_lot] = 0;
|
||||
lotMap[r.resolved_lot] += r.quantity;
|
||||
});
|
||||
|
||||
const items = Object.entries(lotMap).map(([lot, qty]) => ({
|
||||
lot_name: lot,
|
||||
quantity: qty,
|
||||
unit_price: 0
|
||||
}));
|
||||
|
||||
if (!confirm(`Пересчитать Estimate? Текущая корзина будет заменена ${items.length} позициями из BOM.`)) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/apply`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showToast('Estimate обновлён из BOM', 'success');
|
||||
// Reload the page to show updated estimate
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing BOM on config load
|
||||
async function loadVendorSpec(configUUID) {
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (data.vendor_spec && data.vendor_spec.length) {
|
||||
bomRows = data.vendor_spec.map((r, i) => ({
|
||||
sort_order: r.sort_order || (i + 1) * 10,
|
||||
vendor_pn: r.vendor_partnumber,
|
||||
quantity: r.quantity,
|
||||
description: r.description || '',
|
||||
unit_price: r.unit_price || null,
|
||||
total_price: r.total_price || null,
|
||||
resolved_lot: r.resolved_lot_name || '',
|
||||
resolution_source: r.resolution_source || 'unresolved',
|
||||
manual_lot: r.manual_lot_suggestion || ''
|
||||
}));
|
||||
renderBOMTable();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load vendor spec:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||||
|
||||
function renderPricingTab() {
|
||||
const tbody = document.getElementById('pricing-table-body');
|
||||
const tfoot = document.getElementById('pricing-table-foot');
|
||||
|
||||
if (!bomRows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>';
|
||||
tfoot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const cart = window._currentCart || [];
|
||||
const cartMap = {};
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item; });
|
||||
|
||||
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0;
|
||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
bomRows.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||
const cartItem = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
||||
const estimatePrice = cartItem ? cartItem.unit_price : null;
|
||||
|
||||
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
|
||||
if (estimatePrice != null) { totalEstimate += estimatePrice * row.quantity; hasEstimate = true; }
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||||
<td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimatePrice != null ? formatCurrency(estimatePrice * row.quantity) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Totals row
|
||||
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
|
||||
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
|
||||
document.getElementById('pricing-total-warehouse').textContent = '—';
|
||||
tfoot.classList.remove('hidden');
|
||||
|
||||
// Update custom price discount info
|
||||
onPricingCustomPriceInput();
|
||||
}
|
||||
|
||||
function setPricingCustomPriceFromVendor() {
|
||||
let totalVendor = 0;
|
||||
bomRows.forEach(r => {
|
||||
const vt = r.total_price != null ? r.total_price : (r.unit_price != null ? r.unit_price * r.quantity : 0);
|
||||
totalVendor += vt;
|
||||
});
|
||||
document.getElementById('pricing-custom-price').value = totalVendor.toFixed(2);
|
||||
onPricingCustomPriceInput();
|
||||
}
|
||||
|
||||
function onPricingCustomPriceInput() {
|
||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
||||
// Compute estimate total
|
||||
const cart = window._currentCart || [];
|
||||
let estimateTotal = 0;
|
||||
cart.forEach(item => { estimateTotal += (item.unit_price || 0) * item.quantity; });
|
||||
|
||||
const discountEl = document.getElementById('pricing-discount-info');
|
||||
const pctEl = document.getElementById('pricing-discount-pct');
|
||||
|
||||
if (customPrice > 0 && estimateTotal > 0) {
|
||||
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
|
||||
pctEl.textContent = discount + '%';
|
||||
discountEl.classList.remove('hidden');
|
||||
} else {
|
||||
discountEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function formatCurrency(val) {
|
||||
if (val == null) return '—';
|
||||
return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
// ==================== AUTOCOMPLETE LIST FOR BOM ====================
|
||||
// Inject datalist for lot autocomplete in BOM inline inputs
|
||||
(function() {
|
||||
const dl = document.createElement('datalist');
|
||||
dl.id = 'lot-autocomplete-list';
|
||||
document.body.appendChild(dl);
|
||||
|
||||
// Populate datalist once allComponents is available
|
||||
function populateLotDatalist() {
|
||||
if (!window.allComponents || !window.allComponents.length) return;
|
||||
dl.innerHTML = '';
|
||||
window.allComponents.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.lot_name;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Try after a short delay (components load async)
|
||||
setTimeout(populateLotDatalist, 2000);
|
||||
})();
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user