Redesign pricing tab: split into purchase/sale tables with unit prices
- Split into two sections: Цена покупки and Цена продажи - All price cells show unit price (per 1 pcs); totals only in footer - Added note "Цены указаны за 1 шт." next to each table heading - Buy table: Своя цена redistributes proportionally with green/red coloring vs estimate; footer shows % diff - Sale table: configurable uplift (default 1.3) applied to estimate; Склад/Конкуренты fixed at ×1.3 - Footer Склад/Конкуренты marked red with asterisk tooltip when coverage is partial - CSV export updated: all 8 columns, SPEC-BUY/SPEC-SALE suffix, no % annotation in totals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -199,58 +199,106 @@
|
||||
</div><!-- end top-section-bom -->
|
||||
|
||||
<!-- Top-tab section: Ценообразование -->
|
||||
<div id="top-section-pricing" class="hidden">
|
||||
<div id="top-section-pricing" class="hidden space-y-6">
|
||||
|
||||
<!-- === Цена покупки === -->
|
||||
<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">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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-table-body">
|
||||
<tr><td colspan="8" 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="4" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-competitor">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap 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()">
|
||||
<label class="text-sm font-medium text-gray-700">Uplift:</label>
|
||||
<input type="text" id="pricing-uplift" inputmode="decimal" placeholder="1,0000"
|
||||
class="w-32 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onPricingUpliftInput()">
|
||||
<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
|
||||
</button>
|
||||
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
</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 class="flex items-baseline gap-3 mb-3">
|
||||
<h3 class="text-base font-semibold text-gray-800">Цена покупки</h3>
|
||||
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
|
||||
</div>
|
||||
<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">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-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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<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
|
||||
</button>
|
||||
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === Цена продажи === -->
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex items-baseline gap-3 mb-1">
|
||||
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
|
||||
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-3">Склад и Конкуренты умножаются на 1,3</p>
|
||||
<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">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-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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700">Аплифт к estimate:</label>
|
||||
<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>
|
||||
<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()">
|
||||
<button onclick="exportPricingCSV('sale')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end top-section-pricing -->
|
||||
|
||||
</div>
|
||||
@@ -3481,8 +3529,10 @@ async function loadVendorSpec(configUUID) {
|
||||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||||
|
||||
async function renderPricingTab() {
|
||||
const tbody = document.getElementById('pricing-table-body');
|
||||
const tfoot = document.getElementById('pricing-table-foot');
|
||||
const tbodyBuy = document.getElementById('pricing-body-buy');
|
||||
const tfootBuy = document.getElementById('pricing-foot-buy');
|
||||
const tbodySale = document.getElementById('pricing-body-sale');
|
||||
const tfootSale = document.getElementById('pricing-foot-sale');
|
||||
|
||||
const cart = window._currentCart || [];
|
||||
const compMap = {};
|
||||
@@ -3513,7 +3563,6 @@ async function renderPricingTab() {
|
||||
});
|
||||
}
|
||||
});
|
||||
// Also price LOTs that exist in current Estimate but are not covered by BOM mappings.
|
||||
cart.forEach(item => {
|
||||
if (!item?.lot_name || seen.has(item.lot_name)) return;
|
||||
seen.add(item.lot_name);
|
||||
@@ -3524,7 +3573,7 @@ async function renderPricingTab() {
|
||||
}
|
||||
|
||||
// Fetch fresh price levels for these LOTs
|
||||
const priceMap = {}; // lot_name → {estimate_price, ...}
|
||||
const priceMap = {};
|
||||
if (itemsForPriceLevels.length) {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -3543,224 +3592,207 @@ async function renderPricingTab() {
|
||||
const data = await resp.json();
|
||||
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
|
||||
}
|
||||
} catch(e) { /* silent — pricing tab renders with available data */ }
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0, totalCompetitor = 0;
|
||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false, hasCompetitor = false;
|
||||
// Sale uplift applied to estimate (default 1.3)
|
||||
const saleUplift = (() => {
|
||||
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
return v > 0 ? v : 1.3;
|
||||
})();
|
||||
const SALE_FIXED_MULT = 1.3;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!bomRows.length) {
|
||||
if (!cart.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tfoot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
cart.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row');
|
||||
const pl = priceMap[item.lot_name];
|
||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
const estimateTotal = estUnit * item.quantity;
|
||||
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
|
||||
const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null;
|
||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||||
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
|
||||
if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; }
|
||||
tr.dataset.est = estimateTotal;
|
||||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||||
tr.dataset.vendorOrig = '';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const coveredLots = new Set();
|
||||
bomRows.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row');
|
||||
const baseLot = rowBaseLot(row);
|
||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||
if (baseLot) coveredLots.add(baseLot);
|
||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
||||
const hasMapping = !!baseLot || allocs.length > 0;
|
||||
const isUnresolved = !hasMapping;
|
||||
|
||||
let rowEst = 0;
|
||||
let hasEstimateForRow = false;
|
||||
let rowWarehouse = 0;
|
||||
let hasWarehouseForRow = false;
|
||||
let rowCompetitor = 0;
|
||||
let hasCompetitorForRow = false;
|
||||
if (baseLot) {
|
||||
const pl = priceMap[baseLot];
|
||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
if (estimateUnit != null) {
|
||||
rowEst += estimateUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasEstimateForRow = true;
|
||||
}
|
||||
if (warehouseUnit != null) {
|
||||
rowWarehouse += warehouseUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasWarehouseForRow = true;
|
||||
}
|
||||
if (competitorUnit != null) {
|
||||
rowCompetitor += competitorUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasCompetitorForRow = true;
|
||||
}
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const pl = priceMap[a.lot_name];
|
||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
if (estimateUnit != null) {
|
||||
rowEst += estimateUnit * row.quantity * a.quantity;
|
||||
hasEstimateForRow = true;
|
||||
}
|
||||
if (warehouseUnit != null) {
|
||||
rowWarehouse += warehouseUnit * row.quantity * a.quantity;
|
||||
hasWarehouseForRow = true;
|
||||
}
|
||||
if (competitorUnit != null) {
|
||||
rowCompetitor += competitorUnit * row.quantity * a.quantity;
|
||||
hasCompetitorForRow = true;
|
||||
}
|
||||
});
|
||||
|
||||
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 (hasEstimateForRow) { totalEstimate += rowEst; hasEstimate = true; }
|
||||
if (hasWarehouseForRow) { totalWarehouse += rowWarehouse; hasWarehouse = true; }
|
||||
if (hasCompetitorForRow) { totalCompetitor += rowCompetitor; hasCompetitor = true; }
|
||||
|
||||
tr.dataset.est = rowEst;
|
||||
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
|
||||
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
||||
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>` : ''}`;
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${lotCell}</td>
|
||||
<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 text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasEstimateForRow ? formatCurrency(rowEst) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasCompetitorForRow ? formatCurrency(rowCompetitor) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Append Estimate-only LOTs that were counted in cart but not mapped from BOM.
|
||||
cart.forEach(item => {
|
||||
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row');
|
||||
tr.classList.add('bg-blue-50');
|
||||
const pl = priceMap[item.lot_name];
|
||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
const estimateTotal = estUnit * item.quantity;
|
||||
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
|
||||
const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null;
|
||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||||
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
|
||||
if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; }
|
||||
tr.dataset.est = estimateTotal;
|
||||
tr.dataset.vendorOrig = '';
|
||||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'}</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 = hasWarehouse ? formatCurrency(totalWarehouse) : '—';
|
||||
document.getElementById('pricing-total-competitor').textContent = hasCompetitor ? formatCurrency(totalCompetitor) : '—';
|
||||
tfoot.classList.remove('hidden');
|
||||
|
||||
// Update custom price proportional breakdown
|
||||
onPricingCustomPriceInput();
|
||||
}
|
||||
|
||||
function setPricingCustomPriceFromVendor() {
|
||||
// Apply per-row BOM prices directly (not proportional redistribution)
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
rows.forEach((tr, i) => {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
const orig = tr.dataset.vendorOrig;
|
||||
if (orig !== '') {
|
||||
const v = parseFloat(orig);
|
||||
cell.textContent = formatCurrency(v);
|
||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||||
total += v;
|
||||
hasAny = true;
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.classList.add('text-gray-400');
|
||||
cell.classList.remove('text-blue-700');
|
||||
}
|
||||
// Helper: returns unit prices from pricelist for a single LOT
|
||||
const _getUnitPrices = (pl) => ({
|
||||
estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0,
|
||||
warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null,
|
||||
competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_price : null,
|
||||
});
|
||||
|
||||
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
||||
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
||||
syncPricingLinkedInputs('price');
|
||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||
const _buildRows = () => {
|
||||
const result = [];
|
||||
const coveredLots = new Set();
|
||||
|
||||
// Update discount info only
|
||||
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
let estimateTotal = 0;
|
||||
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||||
const discountEl = document.getElementById('pricing-discount-info');
|
||||
const pctEl = document.getElementById('pricing-discount-pct');
|
||||
if (hasAny && total > 0 && estimateTotal > 0) {
|
||||
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%';
|
||||
discountEl.classList.remove('hidden');
|
||||
const _pushCartRow = (item, isEstOnly) => {
|
||||
const pl = priceMap[item.lot_name];
|
||||
const u = _getUnitPrices(pl);
|
||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||
result.push({
|
||||
lotCell: escapeHtml(item.lot_name), vendorPN: null,
|
||||
desc: (compMap[item.lot_name] || {}).description || '',
|
||||
qty: item.quantity,
|
||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||
est: estUnit * item.quantity,
|
||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
|
||||
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
|
||||
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
|
||||
});
|
||||
};
|
||||
|
||||
if (!bomRows.length) {
|
||||
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||
return { result, coveredLots };
|
||||
}
|
||||
|
||||
bomRows.forEach(row => {
|
||||
const baseLot = rowBaseLot(row);
|
||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// Estimate-only LOTs (cart items not covered by BOM)
|
||||
cart.forEach(item => {
|
||||
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
|
||||
_pushCartRow(item, true);
|
||||
coveredLots.add(item.lot_name);
|
||||
});
|
||||
|
||||
return { result, coveredLots };
|
||||
};
|
||||
|
||||
const { result: rowData } = _buildRows();
|
||||
|
||||
// ─── 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>';
|
||||
tfootBuy.classList.add('hidden');
|
||||
} else {
|
||||
discountEl.classList.add('hidden');
|
||||
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
|
||||
let cntWh = 0, cntComp = 0;
|
||||
rowData.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row-buy');
|
||||
if (r.isEstOnly) tr.classList.add('bg-blue-50');
|
||||
tr.dataset.est = r.est;
|
||||
tr.dataset.qty = r.qty;
|
||||
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
|
||||
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; }
|
||||
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>
|
||||
`;
|
||||
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);
|
||||
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>';
|
||||
tfootSale.classList.add('hidden');
|
||||
} else {
|
||||
let totEst = 0, totWh = 0, totComp = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false;
|
||||
let cntWh = 0, cntComp = 0;
|
||||
rowData.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row-sale');
|
||||
if (r.isEstOnly) tr.classList.add('bg-blue-50');
|
||||
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
|
||||
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
|
||||
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
|
||||
const saleEstTotal = saleEstUnit * r.qty;
|
||||
const saleWhTotal = saleWhUnit != null ? saleWhUnit * r.qty : null;
|
||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
||||
tr.dataset.estSale = saleEstTotal;
|
||||
tr.dataset.qty = r.qty;
|
||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
|
||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
|
||||
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>
|
||||
`;
|
||||
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);
|
||||
tfootSale.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Restore custom prices after re-render
|
||||
applyCustomPrice('buy');
|
||||
applyCustomPrice('sale');
|
||||
}
|
||||
|
||||
function getPricingEstimateTotal() {
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
let estimateTotal = 0;
|
||||
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||||
return estimateTotal;
|
||||
// ─── 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDecimalInput(raw) {
|
||||
@@ -3775,99 +3807,144 @@ function formatUpliftInput(value) {
|
||||
return value.toFixed(4).replace('.', ',');
|
||||
}
|
||||
|
||||
function syncPricingLinkedInputs(source) {
|
||||
const customPriceInput = document.getElementById('pricing-custom-price');
|
||||
const upliftInput = document.getElementById('pricing-uplift');
|
||||
if (!customPriceInput || !upliftInput) return;
|
||||
const estimateTotal = getPricingEstimateTotal();
|
||||
if (estimateTotal <= 0) {
|
||||
upliftInput.value = '';
|
||||
return;
|
||||
}
|
||||
if (source === 'price') {
|
||||
const customPrice = parseFloat(customPriceInput.value) || 0;
|
||||
upliftInput.value = customPrice > 0 ? formatUpliftInput(customPrice / estimateTotal) : '';
|
||||
return;
|
||||
}
|
||||
if (source === 'uplift') {
|
||||
const uplift = parseDecimalInput(upliftInput.value);
|
||||
customPriceInput.value = uplift > 0 ? (estimateTotal * uplift).toFixed(2) : '';
|
||||
}
|
||||
function _getPricingEstimateTotal(table) {
|
||||
const attr = table === 'sale' ? 'estSale' : 'est';
|
||||
const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
||||
let total = 0;
|
||||
document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
|
||||
total += parseFloat(tr.dataset[attr]) || 0;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
function onPricingUpliftInput() {
|
||||
syncPricingLinkedInputs('uplift');
|
||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
||||
applyPricingCustomPrice(customPrice);
|
||||
}
|
||||
// Apply custom (own) price proportionally to Ручная цена column.
|
||||
// table: 'buy' | 'sale'
|
||||
function applyCustomPrice(table) {
|
||||
const inputId = `pricing-custom-price-${table}`;
|
||||
const totalElId = `pricing-total-${table}-vendor`;
|
||||
const rowClass = `pricing-row-${table}`;
|
||||
const cellClass = `.pricing-vendor-price-${table}`;
|
||||
const estAttr = table === 'sale' ? 'estSale' : 'est';
|
||||
const origAttr = table === 'buy' ? 'vendorOrig' : null;
|
||||
|
||||
function onPricingCustomPriceInput() {
|
||||
syncPricingLinkedInputs('price');
|
||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
||||
applyPricingCustomPrice(customPrice);
|
||||
}
|
||||
const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
|
||||
const estimateTotal = _getPricingEstimateTotal(table);
|
||||
const rows = document.querySelectorAll(`#pricing-body-${table} tr.${rowClass}`);
|
||||
const vendorCells = document.querySelectorAll(`#pricing-body-${table} ${cellClass}`);
|
||||
const totalVendorEl = document.getElementById(totalElId);
|
||||
|
||||
function applyPricingCustomPrice(customPrice) {
|
||||
const estimateTotal = getPricingEstimateTotal();
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
|
||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
||||
const _pctLabel = (custom, est) => {
|
||||
if (est <= 0) return '';
|
||||
const pct = ((est - custom) / est * 100);
|
||||
const sign = pct >= 0 ? '-' : '+';
|
||||
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
||||
};
|
||||
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
if (customPrice > 0 && estimateTotal > 0) {
|
||||
// Proportionally redistribute custom price → Цена проектная cells
|
||||
let assigned = 0;
|
||||
rows.forEach((tr, i) => {
|
||||
const est = parseFloat(tr.dataset.est) || 0;
|
||||
const rowEst = parseFloat(tr.dataset[estAttr]) || 0;
|
||||
const qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
let share;
|
||||
if (i === rows.length - 1) {
|
||||
share = customPrice - assigned;
|
||||
} else {
|
||||
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
|
||||
share = Math.round((rowEst / estimateTotal) * customPrice * 100) / 100;
|
||||
assigned += share;
|
||||
}
|
||||
cell.textContent = formatCurrency(share);
|
||||
cell.classList.add('text-blue-700');
|
||||
cell.classList.remove('text-gray-400');
|
||||
cell.textContent = formatCurrency(share / qty);
|
||||
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||
cell.classList.add(rowEst > 0 ? _pctClass(share, rowEst) : 'text-blue-700');
|
||||
});
|
||||
totalVendorEl.textContent = formatCurrency(customPrice);
|
||||
const pctStr = _pctLabel(customPrice, estimateTotal);
|
||||
totalVendorEl.textContent = formatCurrency(customPrice) + pctStr;
|
||||
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||
totalVendorEl.classList.add(_pctClass(customPrice, estimateTotal));
|
||||
} else {
|
||||
// Restore original vendor prices from BOM
|
||||
// Restore originals
|
||||
rows.forEach((tr, i) => {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
const orig = tr.dataset.vendorOrig;
|
||||
if (orig !== '') {
|
||||
cell.textContent = formatCurrency(parseFloat(orig));
|
||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||||
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||
if (origAttr && tr.dataset.vendorOrigUnit !== '') {
|
||||
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.classList.add('text-gray-400');
|
||||
cell.classList.remove('text-blue-700');
|
||||
}
|
||||
});
|
||||
// Recompute vendor total from originals
|
||||
let origTotal = 0; let hasOrig = false;
|
||||
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
||||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||||
}
|
||||
|
||||
// Discount info
|
||||
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');
|
||||
// Recompute total from originals (buy) or clear (sale)
|
||||
if (origAttr) {
|
||||
let origTotal = 0; let hasOrig = false;
|
||||
rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
|
||||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||||
} else {
|
||||
// sale: reset to — already handled above
|
||||
totalVendorEl.textContent = '—';
|
||||
}
|
||||
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
function exportPricingCSV() {
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
function onBuyCustomPriceInput() {
|
||||
applyCustomPrice('buy');
|
||||
}
|
||||
|
||||
function onSaleCustomPriceInput() {
|
||||
applyCustomPrice('sale');
|
||||
}
|
||||
|
||||
function onSaleMarkupInput() {
|
||||
renderPricingTab();
|
||||
}
|
||||
|
||||
function setPricingCustomPriceFromVendor() {
|
||||
// Fill Ручная цена in Buy table from BOM vendor totals
|
||||
const rows = document.querySelectorAll('#pricing-body-buy tr.pricing-row-buy');
|
||||
const vendorCells = document.querySelectorAll('#pricing-body-buy .pricing-vendor-price-buy');
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
rows.forEach((tr, i) => {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
const origUnit = tr.dataset.vendorOrigUnit;
|
||||
const origTotal = tr.dataset.vendorOrig;
|
||||
if (origUnit !== '') {
|
||||
cell.textContent = formatCurrency(parseFloat(origUnit));
|
||||
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||
total += parseFloat(origTotal) || 0;
|
||||
hasAny = true;
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.className = cell.className.replace(/\btext-(?:green|red|blue)-\d+\b/g, '').trim();
|
||||
cell.classList.add('text-gray-400');
|
||||
}
|
||||
});
|
||||
const estimateTotal = _getPricingEstimateTotal('buy');
|
||||
const totalEl = document.getElementById('pricing-total-buy-vendor');
|
||||
if (hasAny) {
|
||||
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
|
||||
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
|
||||
totalEl.textContent = formatCurrency(total) + pct;
|
||||
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
|
||||
} else {
|
||||
document.getElementById('pricing-custom-price-buy').value = '';
|
||||
totalEl.textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function exportPricingCSV(table) {
|
||||
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
|
||||
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
||||
const totalIds = table === 'sale'
|
||||
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
|
||||
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
|
||||
|
||||
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
|
||||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||||
|
||||
const csvEscape = v => {
|
||||
@@ -3876,35 +3953,34 @@ function exportPricingCSV() {
|
||||
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
const headers = ['Lot', 'P/N вендора', 'Описание', 'Кол-во', 'Цена проектная'];
|
||||
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
|
||||
rows.forEach(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const lot = cells[0] ? cells[0].textContent.trim() : '';
|
||||
const vendorPN = cells[1] ? cells[1].textContent.trim() : '';
|
||||
const description = cells[2] ? cells[2].textContent.trim() : '';
|
||||
const qty = cells[3] ? cells[3].textContent.trim() : '';
|
||||
const vendorPrice = cells[5] ? cells[5].textContent.trim() : '';
|
||||
lines.push([lot, vendorPN, description, qty, vendorPrice].map(csvEscape).join(','));
|
||||
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cells[i].textContent.trim() : '');
|
||||
lines.push(cols.map(csvEscape).join(','));
|
||||
});
|
||||
|
||||
// Totals row
|
||||
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
|
||||
lines.push(['', '', '', 'Итого:', vendorTotal].map(csvEscape).join(','));
|
||||
const tEst = document.getElementById(totalIds.est)?.textContent.trim() || '';
|
||||
const tWh = document.getElementById(totalIds.wh)?.textContent.trim() || '';
|
||||
const tComp = document.getElementById(totalIds.comp)?.textContent.trim() || '';
|
||||
const tVendor = document.getElementById(totalIds.vendor)?.textContent.trim() || '';
|
||||
// Strip % annotation from vendor total for CSV
|
||||
const tVendorClean = tVendor.replace(/\s*\(.*\)$/, '').trim();
|
||||
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendorClean].map(csvEscape).join(','));
|
||||
|
||||
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const datePart = `${yyyy}-${mm}-${dd}`;
|
||||
const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
||||
const codePart = (projectCode || 'NO-PROJECT').trim();
|
||||
const namePart = (configName || 'config').trim();
|
||||
a.download = `${datePart} (${codePart}) ${namePart} SPEC.csv`;
|
||||
const suffix = table === 'sale' ? 'SALE' : 'BUY';
|
||||
a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user