diff --git a/web/templates/index.html b/web/templates/index.html
index 991833f..474fe3c 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -199,58 +199,106 @@
-
+
+
+
-
-
-
-
-
- | LOT |
- PN вендора |
- Описание |
- Кол-во |
- Estimate |
- Цена проектная |
- Склад |
- Конк. |
-
-
-
- | Загрузите BOM во вкладке «BOM» |
-
-
-
-
-
-
-
-
-
-
-
-
- Скидка от Estimate:
-
-
+
+
Цена покупки
+ Цены указаны за 1 шт.
+
+
+
+
+
+ | LOT |
+ PN вендора |
+ Описание |
+ Кол-во |
+ Estimate |
+ Склад |
+ Конкуренты |
+ Ручная цена |
+
+
+
+ | Загрузите BOM во вкладке «BOM» |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Цена продажи
+ Цены указаны за 1 шт.
+
+
Склад и Конкуренты умножаются на 1,3
+
+
+
+
+ | LOT |
+ PN вендора |
+ Описание |
+ Кол-во |
+ Estimate |
+ Склад |
+ Конкуренты |
+ Ручная цена |
+
+
+
+ | Загрузите BOM во вкладке «BOM» |
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 = '
| Нет данных для отображения |
';
- 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 = `
-
${escapeHtml(item.lot_name)} |
-
— |
-
${escapeHtml(desc)} |
-
${item.quantity} |
-
${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'} |
-
— |
-
${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'} |
-
${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'} |
- `;
- 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 = '
н/д';
- if (baseLot && allocs.length) {
- lotCell = `${escapeHtml(baseLot)}
+${allocs.length}`;
- } else if (baseLot) {
- lotCell = escapeHtml(baseLot);
- } else if (allocs.length) {
- lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? `
+${allocs.length - 1}` : ''}`;
- }
- tr.innerHTML = `
-
${lotCell} |
-
${escapeHtml(row.vendor_pn)} |
-
${escapeHtml(desc)} |
-
${row.quantity} |
-
${hasEstimateForRow ? formatCurrency(rowEst) : '—'} |
-
${vendorTotal != null ? formatCurrency(vendorTotal) : '—'} |
-
${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'} |
-
${hasCompetitorForRow ? formatCurrency(rowCompetitor) : '—'} |
- `;
- 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 = `
-
${escapeHtml(item.lot_name)} |
-
— |
-
${escapeHtml(desc)} |
-
${item.quantity} |
-
${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'} |
-
— |
-
${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'} |
-
${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'} |
- `;
- 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 = '
н/д';
+ if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)}
+${allocs.length}`;
+ else if (baseLot) lotCell = escapeHtml(baseLot);
+ else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? `
+${allocs.length - 1}` : ''}`;
+
+ 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 = '
| Нет данных для отображения |
';
+ 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 = `
+
${r.lotCell} |
+
${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} |
+
${escapeHtml(r.desc)} |
+
${r.qty} |
+
${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} |
+
${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} |
+
${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} |
+
${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'} |
+ `;
+ 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 = '
| Нет данных для отображения |
';
+ 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 = `
+
${r.lotCell} |
+
${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} |
+
${escapeHtml(r.desc)} |
+
${r.qty} |
+
${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'} |
+
${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'} |
+
${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'} |
+
— |
+ `;
+ 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 = `
${formatCurrency(total)} *`;
+ } 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);
}