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,9 +199,14 @@
|
|||||||
</div><!-- end top-section-bom -->
|
</div><!-- end top-section-bom -->
|
||||||
|
|
||||||
<!-- Top-tab section: Ценообразование -->
|
<!-- 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 class="bg-white rounded-lg shadow p-4">
|
||||||
<div id="pricing-table-container">
|
<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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<thead class="bg-gray-50 text-gray-700">
|
||||||
@@ -211,46 +216,89 @@
|
|||||||
<th class="px-3 py-2 text-left 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-right border-b">Estimate</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-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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pricing-table-body">
|
<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-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
|
<tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
<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" id="pricing-total-buy-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-buy-warehouse">—</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-buy-competitor">—</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-competitor">—</td>
|
<td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor">—</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
||||||
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
|
<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"
|
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
oninput="onPricingCustomPriceInput()">
|
oninput="onBuyCustomPriceInput()">
|
||||||
<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">
|
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
||||||
Проставить цены BOM
|
Проставить цены BOM
|
||||||
</button>
|
</button>
|
||||||
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||||
Экспорт CSV
|
Экспорт CSV
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
<!-- === Цена продажи === -->
|
||||||
|
<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>
|
</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><!-- end top-section-pricing -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -3481,8 +3529,10 @@ async function loadVendorSpec(configUUID) {
|
|||||||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||||||
|
|
||||||
async function renderPricingTab() {
|
async function renderPricingTab() {
|
||||||
const tbody = document.getElementById('pricing-table-body');
|
const tbodyBuy = document.getElementById('pricing-body-buy');
|
||||||
const tfoot = document.getElementById('pricing-table-foot');
|
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 cart = window._currentCart || [];
|
||||||
const compMap = {};
|
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 => {
|
cart.forEach(item => {
|
||||||
if (!item?.lot_name || seen.has(item.lot_name)) return;
|
if (!item?.lot_name || seen.has(item.lot_name)) return;
|
||||||
seen.add(item.lot_name);
|
seen.add(item.lot_name);
|
||||||
@@ -3524,7 +3573,7 @@ async function renderPricingTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh price levels for these LOTs
|
// Fetch fresh price levels for these LOTs
|
||||||
const priceMap = {}; // lot_name → {estimate_price, ...}
|
const priceMap = {};
|
||||||
if (itemsForPriceLevels.length) {
|
if (itemsForPriceLevels.length) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -3543,224 +3592,207 @@ async function renderPricingTab() {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
|
(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;
|
// Sale uplift applied to estimate (default 1.3)
|
||||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false, hasCompetitor = false;
|
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 = '';
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||||
|
const _buildRows = () => {
|
||||||
|
const result = [];
|
||||||
|
const coveredLots = new Set();
|
||||||
|
|
||||||
|
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) {
|
if (!bomRows.length) {
|
||||||
if (!cart.length) {
|
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
return { result, coveredLots };
|
||||||
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 => {
|
bomRows.forEach(row => {
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.classList.add('pricing-row');
|
|
||||||
const baseLot = rowBaseLot(row);
|
const baseLot = rowBaseLot(row);
|
||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot) coveredLots.add(baseLot);
|
if (baseLot) coveredLots.add(baseLot);
|
||||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
allocs.forEach(a => coveredLots.add(a.lot_name));
|
||||||
const hasMapping = !!baseLot || allocs.length > 0;
|
|
||||||
const isUnresolved = !hasMapping;
|
|
||||||
|
|
||||||
let rowEst = 0;
|
// Accumulate unit prices per 1 vendor PN (base + allocs)
|
||||||
let hasEstimateForRow = false;
|
let rowEstUnit = 0, rowWhUnit = 0, rowCompUnit = 0;
|
||||||
let rowWarehouse = 0;
|
let hasEst = false, hasWh = false, hasComp = false;
|
||||||
let hasWarehouseForRow = false;
|
|
||||||
let rowCompetitor = 0;
|
|
||||||
let hasCompetitorForRow = false;
|
|
||||||
if (baseLot) {
|
if (baseLot) {
|
||||||
const pl = priceMap[baseLot];
|
const u = _getUnitPrices(priceMap[baseLot]);
|
||||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
const lotQty = _getRowLotQtyPerPN(row);
|
||||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
if (u.estUnit > 0) { rowEstUnit += u.estUnit * lotQty; hasEst = true; }
|
||||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * lotQty; hasWh = true; }
|
||||||
if (estimateUnit != null) {
|
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * lotQty; hasComp = true; }
|
||||||
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 => {
|
allocs.forEach(a => {
|
||||||
const pl = priceMap[a.lot_name];
|
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
if (u.estUnit > 0) { rowEstUnit += u.estUnit * a.quantity; hasEst = true; }
|
||||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * a.quantity; hasWh = true; }
|
||||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * a.quantity; hasComp = true; }
|
||||||
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>';
|
let lotCell = '<span class="text-red-500">н/д</span>';
|
||||||
if (baseLot && allocs.length) {
|
if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
|
||||||
lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
|
else if (baseLot) lotCell = escapeHtml(baseLot);
|
||||||
} else if (baseLot) {
|
else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
|
||||||
lotCell = escapeHtml(baseLot);
|
|
||||||
} else if (allocs.length) {
|
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
||||||
lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
|
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
||||||
}
|
const vendorOrig = row.total_price != null ? row.total_price
|
||||||
tr.innerHTML = `
|
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||||
<td class="px-3 py-1.5 text-xs">${lotCell}</td>
|
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
||||||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
result.push({
|
||||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
||||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
estUnit: hasEst ? rowEstUnit : 0,
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${hasEstimateForRow ? formatCurrency(rowEst) : '—'}</td>
|
warehouseUnit: hasWh ? rowWhUnit : null,
|
||||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
competitorUnit: hasComp ? rowCompUnit : null,
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'}</td>
|
est: hasEst ? rowEstUnit * row.quantity : 0,
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${hasCompetitorForRow ? formatCurrency(rowCompetitor) : '—'}</td>
|
warehouse: hasWh ? rowWhUnit * row.quantity : null,
|
||||||
`;
|
competitor: hasComp ? rowCompUnit * row.quantity : null,
|
||||||
tbody.appendChild(tr);
|
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append Estimate-only LOTs that were counted in cart but not mapped from BOM.
|
// Estimate-only LOTs (cart items not covered by BOM)
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
|
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 {
|
||||||
|
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');
|
const tr = document.createElement('tr');
|
||||||
tr.classList.add('pricing-row');
|
tr.classList.add('pricing-row-buy');
|
||||||
tr.classList.add('bg-blue-50');
|
if (r.isEstOnly) tr.classList.add('bg-blue-50');
|
||||||
const pl = priceMap[item.lot_name];
|
tr.dataset.est = r.est;
|
||||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
tr.dataset.qty = r.qty;
|
||||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
|
||||||
const estimateTotal = estUnit * item.quantity;
|
if (r.est > 0) { totEst += r.est; hasEst = true; }
|
||||||
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
|
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
||||||
const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null;
|
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
||||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = 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 = `
|
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">${r.lotCell}</td>
|
||||||
<td class="px-3 py-1.5 text-xs text-gray-400">—</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(desc)}</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">${item.quantity}</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">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</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 text-gray-400 pricing-vendor-price">—</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">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</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">${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'}</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>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totals row
|
// ─── Populate Sale table ─────────────────────────────────────────────────
|
||||||
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
|
tbodySale.innerHTML = '';
|
||||||
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
|
if (!rowData.length) {
|
||||||
document.getElementById('pricing-total-warehouse').textContent = hasWarehouse ? formatCurrency(totalWarehouse) : '—';
|
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||||
document.getElementById('pricing-total-competitor').textContent = hasCompetitor ? formatCurrency(totalCompetitor) : '—';
|
tfootSale.classList.add('hidden');
|
||||||
tfoot.classList.remove('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');
|
||||||
|
}
|
||||||
|
|
||||||
// Update custom price proportional breakdown
|
// Restore custom prices after re-render
|
||||||
onPricingCustomPriceInput();
|
applyCustomPrice('buy');
|
||||||
|
applyCustomPrice('sale');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPricingCustomPriceFromVendor() {
|
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||||
// 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) => {
|
// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
|
||||||
const cell = vendorCells[i];
|
function _setPartialTotal(elId, has, total, count, totalRows) {
|
||||||
if (!cell) return;
|
const el = document.getElementById(elId);
|
||||||
const orig = tr.dataset.vendorOrig;
|
if (!el) return;
|
||||||
if (orig !== '') {
|
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
|
||||||
const v = parseFloat(orig);
|
if (!has) { el.textContent = '—'; return; }
|
||||||
cell.textContent = formatCurrency(v);
|
if (count < totalRows) {
|
||||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`;
|
||||||
total += v;
|
|
||||||
hasAny = true;
|
|
||||||
} else {
|
} else {
|
||||||
cell.textContent = '—';
|
el.textContent = formatCurrency(total);
|
||||||
cell.classList.add('text-gray-400');
|
|
||||||
cell.classList.remove('text-blue-700');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
|
||||||
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
|
||||||
syncPricingLinkedInputs('price');
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
} else {
|
|
||||||
discountEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimalInput(raw) {
|
function parseDecimalInput(raw) {
|
||||||
@@ -3775,99 +3807,144 @@ function formatUpliftInput(value) {
|
|||||||
return value.toFixed(4).replace('.', ',');
|
return value.toFixed(4).replace('.', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPricingLinkedInputs(source) {
|
function _getPricingEstimateTotal(table) {
|
||||||
const customPriceInput = document.getElementById('pricing-custom-price');
|
const attr = table === 'sale' ? 'estSale' : 'est';
|
||||||
const upliftInput = document.getElementById('pricing-uplift');
|
const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
||||||
if (!customPriceInput || !upliftInput) return;
|
let total = 0;
|
||||||
const estimateTotal = getPricingEstimateTotal();
|
document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
|
||||||
if (estimateTotal <= 0) {
|
total += parseFloat(tr.dataset[attr]) || 0;
|
||||||
upliftInput.value = '';
|
});
|
||||||
return;
|
return total;
|
||||||
}
|
|
||||||
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 onPricingUpliftInput() {
|
// Apply custom (own) price proportionally to Ручная цена column.
|
||||||
syncPricingLinkedInputs('uplift');
|
// table: 'buy' | 'sale'
|
||||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
function applyCustomPrice(table) {
|
||||||
applyPricingCustomPrice(customPrice);
|
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() {
|
const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
|
||||||
syncPricingLinkedInputs('price');
|
const estimateTotal = _getPricingEstimateTotal(table);
|
||||||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
const rows = document.querySelectorAll(`#pricing-body-${table} tr.${rowClass}`);
|
||||||
applyPricingCustomPrice(customPrice);
|
const vendorCells = document.querySelectorAll(`#pricing-body-${table} ${cellClass}`);
|
||||||
}
|
const totalVendorEl = document.getElementById(totalElId);
|
||||||
|
|
||||||
function applyPricingCustomPrice(customPrice) {
|
const _pctLabel = (custom, est) => {
|
||||||
const estimateTotal = getPricingEstimateTotal();
|
if (est <= 0) return '';
|
||||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
const pct = ((est - custom) / est * 100);
|
||||||
|
const sign = pct >= 0 ? '-' : '+';
|
||||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
||||||
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
};
|
||||||
|
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
|
||||||
|
|
||||||
if (customPrice > 0 && estimateTotal > 0) {
|
if (customPrice > 0 && estimateTotal > 0) {
|
||||||
// Proportionally redistribute custom price → Цена проектная cells
|
|
||||||
let assigned = 0;
|
let assigned = 0;
|
||||||
rows.forEach((tr, i) => {
|
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];
|
const cell = vendorCells[i];
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
let share;
|
let share;
|
||||||
if (i === rows.length - 1) {
|
if (i === rows.length - 1) {
|
||||||
share = customPrice - assigned;
|
share = customPrice - assigned;
|
||||||
} else {
|
} else {
|
||||||
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
|
share = Math.round((rowEst / estimateTotal) * customPrice * 100) / 100;
|
||||||
assigned += share;
|
assigned += share;
|
||||||
}
|
}
|
||||||
cell.textContent = formatCurrency(share);
|
cell.textContent = formatCurrency(share / qty);
|
||||||
cell.classList.add('text-blue-700');
|
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||||
cell.classList.remove('text-gray-400');
|
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 {
|
} else {
|
||||||
// Restore original vendor prices from BOM
|
// Restore originals
|
||||||
rows.forEach((tr, i) => {
|
rows.forEach((tr, i) => {
|
||||||
const cell = vendorCells[i];
|
const cell = vendorCells[i];
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
const orig = tr.dataset.vendorOrig;
|
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||||
if (orig !== '') {
|
if (origAttr && tr.dataset.vendorOrigUnit !== '') {
|
||||||
cell.textContent = formatCurrency(parseFloat(orig));
|
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
|
||||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
|
||||||
} else {
|
} else {
|
||||||
cell.textContent = '—';
|
cell.textContent = '—';
|
||||||
cell.classList.add('text-gray-400');
|
cell.classList.add('text-gray-400');
|
||||||
cell.classList.remove('text-blue-700');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Recompute vendor total from originals
|
// Recompute total from originals (buy) or clear (sale)
|
||||||
|
if (origAttr) {
|
||||||
let origTotal = 0; let hasOrig = false;
|
let origTotal = 0; let hasOrig = false;
|
||||||
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
|
||||||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
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 {
|
} else {
|
||||||
discountEl.classList.add('hidden');
|
// sale: reset to — already handled above
|
||||||
|
totalVendorEl.textContent = '—';
|
||||||
|
}
|
||||||
|
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPricingCSV() {
|
function onBuyCustomPriceInput() {
|
||||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
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; }
|
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||||||
|
|
||||||
const csvEscape = v => {
|
const csvEscape = v => {
|
||||||
@@ -3876,35 +3953,34 @@ function exportPricingCSV() {
|
|||||||
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = ['Lot', 'P/N вендора', 'Описание', 'Кол-во', 'Цена проектная'];
|
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||||
const lines = [headers.map(csvEscape).join(',')];
|
const lines = [headers.map(csvEscape).join(',')];
|
||||||
|
|
||||||
rows.forEach(tr => {
|
rows.forEach(tr => {
|
||||||
const cells = tr.querySelectorAll('td');
|
const cells = tr.querySelectorAll('td');
|
||||||
const lot = cells[0] ? cells[0].textContent.trim() : '';
|
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cells[i].textContent.trim() : '');
|
||||||
const vendorPN = cells[1] ? cells[1].textContent.trim() : '';
|
lines.push(cols.map(csvEscape).join(','));
|
||||||
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(','));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Totals row
|
// Totals row
|
||||||
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
|
const tEst = document.getElementById(totalIds.est)?.textContent.trim() || '';
|
||||||
lines.push(['', '', '', 'Итого:', vendorTotal].map(csvEscape).join(','));
|
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 blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yyyy = today.getFullYear();
|
const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
||||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String(today.getDate()).padStart(2, '0');
|
|
||||||
const datePart = `${yyyy}-${mm}-${dd}`;
|
|
||||||
const codePart = (projectCode || 'NO-PROJECT').trim();
|
const codePart = (projectCode || 'NO-PROJECT').trim();
|
||||||
const namePart = (configName || 'config').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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user