feat: накидка по строке в таблице «Цена продажи» + минимальный CSV

Sale-таблица показывает LOT/Описание/Кол-во/Накидка,%/Цена вместо Estimate/
Склад/Конкуренты/Ручная цена; итоговая цена строки = база (raw estimate или
доля от общей «Ручная цена») × (Аплифт к estimate + своя Накидка%). Экспорт
CSV этой таблицы теперь выводит только LOT;Описание;Кол-во;Цена. Buy-таблица
и массовый экспорт по проекту не затронуты — новые поля запроса опциональны
и включаются только кнопкой «Экспорт CSV» у Sale-таблицы.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-09-15 16:39:54 +03:00
co-authored by Claude Sonnet 5
parent fb412a4227
commit 7263dd4572
5 changed files with 385 additions and 100 deletions
+177 -79
View File
@@ -267,7 +267,6 @@
<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">
@@ -277,22 +276,17 @@
<th class="px-2 py-2 text-left border-b">Описание</th>
<th class="px-2 py-2 text-left border-b">LOT</th>
<th class="px-2 py-2 text-right border-b">Кол-во</th>
<th class="px-2 py-2 text-right border-b">Estimate</th>
<th class="px-2 py-2 text-right border-b stock-price-col">Склад</th>
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
<th class="px-2 py-2 text-right border-b w-20">Накидка, %</th>
<th class="px-2 py-2 text-right border-b">Цена</th>
</tr>
</thead>
<tbody id="pricing-body-sale">
<tr><td colspan="{{if eq .IndicatorMode "accessible"}}9{{else}}8{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
<tr><td colspan="{{if eq .IndicatorMode "accessible"}}7{{else}}6{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody>
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="{{if eq .IndicatorMode "accessible"}}5{{else}}4{{end}}" class="px-2 py-2 text-right">Итого:</td>
<td class="px-2 py-2 text-right" id="pricing-total-sale-estimate"></td>
<td class="px-2 py-2 text-right stock-price-col" id="pricing-total-sale-warehouse"></td>
<td class="px-2 py-2 text-right" id="pricing-total-sale-competitor"></td>
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-vendor"></td>
<td colspan="{{if eq .IndicatorMode "accessible"}}6{{else}}5{{end}}" class="px-2 py-2 text-right">Итого:</td>
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-price"></td>
</tr>
</tfoot>
</table>
@@ -612,11 +606,9 @@ let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
// Mirrors internal/services/export.go (saleMarkupFactor / stockCompetitorMarkupFactor).
// Keep both sides in sync: Estimate scales by the user's uplift, Stock/Competitor by a fixed factor.
// Mirrors internal/services/export.go effectiveSaleMarkupFactor/defaultSaleMarkup.
const PricingMarkup = {
DEFAULT_SALE_UPLIFT: 1.3,
STOCK_COMPETITOR_FIXED: 1.3,
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
getSaleUplift() {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
@@ -624,6 +616,22 @@ const PricingMarkup = {
},
};
// Per-row "Накидка" (%) for the Sale table, keyed by _saleRowKey(vendorPN, lot).
// Persisted in Configuration.Notes.pricing_ui.sale_row_markups (see buildPricingState/
// restorePricingStateFromNotes); restored before the table exists, so renderPricingTab()
// reads it live rather than relying on per-render input state.
let saleRowMarkups = {};
// Amber marker for world-fallback cells — must stay outside gray/green/red/blue
// families since applyCustomPrice() strips those on vendor-price cells via regex.
// Shared by renderPricingTab() (initial paint) and recomputeSalePrices() (re-paint on
// input change), so both module-level functions rather than a closure in either one.
const WORLD_CLS = 'bg-amber-50 text-amber-700';
const worldCls = (w) => (window.QF_INDICATOR_MODE !== 'accessible' && w) ? WORLD_CLS : '';
const worldMark = (w) => (window.QF_INDICATOR_MODE === 'accessible' && w)
? '<sup class="text-gray-500 font-semibold" title="Цена-заглушка из прайслиста WORLD">W</sup>'
: '';
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
@@ -2580,10 +2588,18 @@ function buildPricingState() {
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
// Only non-zero entries are kept, so an untouched config serializes exactly like
// before this feature (additive key — old clients simply ignore it).
const rowMarkups = {};
Object.entries(saleRowMarkups).forEach(([key, pct]) => {
if (pct > 0) rowMarkups[key] = pct;
});
return {
buy_custom_price: buyCustom > 0 ? buyCustom : null,
sale_uplift: saleUplift > 0 ? saleUplift : null,
sale_custom_price: saleCustom > 0 ? saleCustom : null,
sale_row_markups: Object.keys(rowMarkups).length ? rowMarkups : null,
};
}
@@ -2625,6 +2641,17 @@ function restorePricingStateFromNotes(notesRaw) {
? pricing.sale_custom_price.toFixed(2)
: '';
}
// Sale-table row is not rendered yet at this point — renderPricingTab() reads
// saleRowMarkups live when it builds each row's Накидка input.
saleRowMarkups = {};
if (pricing.sale_row_markups && typeof pricing.sale_row_markups === 'object') {
Object.entries(pricing.sale_row_markups).forEach(([key, pct]) => {
if (typeof pct === 'number' && Number.isFinite(pct) && pct > 0) {
saleRowMarkups[key] = pct;
}
});
}
}
function getAutosaveStorageKey() {
@@ -4517,9 +4544,6 @@ async function renderPricingTab() {
} catch(e) { /* silent */ }
}
// Sale uplift applied to estimate; Stock/Competitor use the fixed factor. See PricingMarkup.
const saleUplift = PricingMarkup.getSaleUplift();
const SALE_FIXED_MULT = PricingMarkup.STOCK_COMPETITOR_FIXED;
// Helper: returns unit prices from pricelist for a single LOT
const _getUnitPrices = (pl) => ({
@@ -4531,17 +4555,9 @@ async function renderPricingTab() {
compWorld: !!(pl && pl.competitor_from_world),
});
// Amber marker for world-fallback cells — must stay outside gray/green/red/blue
// families since applyCustomPrice() strips those on vendor-price cells via regex.
const WORLD_CLS = 'bg-amber-50 text-amber-700';
// Colour-blind-safe mode: leading signal-meter column, no row tint, and the
// amber world-fallback cell tint is replaced by a text marker.
const ACCESSIBLE_MODE = window.QF_INDICATOR_MODE === 'accessible';
const worldCls = (w) => (!ACCESSIBLE_MODE && w) ? WORLD_CLS : '';
const worldMark = (w) => (ACCESSIBLE_MODE && w)
? '<sup class="text-gray-500 font-semibold" title="Цена-заглушка из прайслиста WORLD">W</sup>'
: '';
const meterCell = (q, bt) => ACCESSIBLE_MODE
? `<td class="pricing-quality-meter px-2 py-1.5 align-middle ${bt || ''}">${qualityMeterHtml(q)}</td>`
: '';
@@ -4729,15 +4745,15 @@ async function renderPricingTab() {
}
// ─── Populate Sale table ─────────────────────────────────────────────────
// Sale rows show one final "Цена" per row: rawEstimate (or a proportional share
// of the global "Ручная цена" when set) multiplied by (Аплифт к estimate + row's
// own Накидка%). See recomputeSalePrices() / _saleRowPrice() for the shared math,
// re-run on every input change and after every render.
tbodySale.innerHTML = '';
if (!rowData.length) {
tbodySale.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 9 : 8}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`;
tbodySale.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 7 : 6}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`;
tfootSale.classList.add('hidden');
} else {
let totEst = 0, totWh = 0, totComp = 0;
let hasEst = false, hasWh = false, hasComp = false;
let cntWh = 0, cntComp = 0;
let worldEst = 0, worldWh = 0, worldComp = 0;
rowData.forEach(r => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row-sale');
@@ -4746,21 +4762,16 @@ async function renderPricingTab() {
} else 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;
const rowKey = _saleRowKey(r.vendorPN, r.lotText);
const markupPct = saleRowMarkups[rowKey] || 0;
tr.dataset.rowKey = rowKey;
tr.dataset.rawEst = r.est;
tr.dataset.qty = r.qty;
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText;
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; }
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; }
tr.dataset.estWorld = r.estWorld ? 'true' : 'false';
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
@@ -4772,23 +4783,100 @@ async function renderPricingTab() {
${pnDescHtml}
<td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.estWorld)}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}${worldMark(r.estWorld)}</td>
<td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${worldCls(r.whWorld)}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}${worldMark(r.whWorld)}</td>
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.compWorld)}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}${worldMark(r.compWorld)}</td>
<td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
<td class="px-2 py-1.5 text-right ${borderTop}">
<input type="text" inputmode="decimal" class="pricing-row-markup w-16 px-1 py-0.5 border rounded text-right text-xs"
value="${formatMarkupInput(markupPct)}"
placeholder="0" oninput="onSaleRowMarkupInput(this)">
</td>
<td class="px-2 py-1.5 text-right text-xs pricing-sale-price ${borderTop} ${worldCls(r.estWorld)}">—${worldMark(r.estWorld)}</td>
`;
tbodySale.appendChild(tr);
});
document.getElementById('pricing-total-sale-vendor').textContent = '—';
_setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
_setPricingTotal('pricing-total-sale-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
_setPricingTotal('pricing-total-sale-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
tfootSale.classList.remove('hidden');
}
// Restore custom prices after re-render
applyCustomPrice('buy');
applyCustomPrice('sale');
recomputeSalePrices();
}
// Composite key for a Sale-table row's per-row "Накидка": the same LOT can appear
// under more than one vendor PN group (see bible-local/02-architecture.md "Per-LOT
// row expansion rules"), so lot_name alone is not a safe map key.
function _saleRowKey(vendorPN, lot) {
const U = s => (s || '').toUpperCase();
return `${U(vendorPN) || 'NONE'}::${U(lot)}`;
}
// Single source of truth for a Sale row's final price: its base (raw estimate, or a
// proportional share of the global "Ручная цена" when set) times (global uplift + this
// row's own Накидка%).
function _saleRowPrice(base, markupPct, saleUplift) {
const factor = saleUplift + ((markupPct || 0) / 100);
return base * factor;
}
// Re-derives every Sale row's displayed price from its raw estimate, the global
// uplift/manual-price inputs, and its own Накидка input. Called after render and on
// every uplift/manual-price/row-markup change — the only place this math happens.
function recomputeSalePrices() {
const saleUplift = PricingMarkup.getSaleUplift();
const customPrice = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
const rows = Array.from(document.querySelectorAll('#pricing-body-sale tr.pricing-row-sale'));
let estimateTotalRaw = 0;
let lastPricedIdx = -1;
rows.forEach((tr, i) => {
const rawEst = parseFloat(tr.dataset.rawEst) || 0;
if (rawEst > 0) { estimateTotalRaw += rawEst; lastPricedIdx = i; }
});
// Same last-row-absorbs-remainder distribution as applyCustomPrice()/
// distributeManualPrice() so the rows' bases sum to customPrice exactly.
let assigned = 0;
let total = 0, worldTotal = 0, hasAny = false, pricedCount = 0;
rows.forEach((tr, i) => {
const rawEst = parseFloat(tr.dataset.rawEst) || 0;
const cell = tr.querySelector('.pricing-sale-price');
if (!cell) return;
if (rawEst <= 0) {
cell.innerHTML = '—';
return;
}
let base;
if (customPrice > 0 && estimateTotalRaw > 0) {
if (i === lastPricedIdx) {
base = Math.round((customPrice - assigned) * 100) / 100;
} else {
base = Math.round((rawEst / estimateTotalRaw) * customPrice * 100) / 100;
assigned += base;
}
} else {
base = rawEst;
}
const markupPct = saleRowMarkups[tr.dataset.rowKey] || 0;
const isWorld = tr.dataset.estWorld === 'true';
const price = _saleRowPrice(base, markupPct, saleUplift);
cell.innerHTML = formatCurrency(price) + worldMark(isWorld);
total += price;
hasAny = true;
pricedCount++;
if (isWorld) worldTotal += price;
});
_setPricingTotal('pricing-total-sale-price', hasAny, total, worldTotal, pricedCount, rows.length);
}
function onSaleRowMarkupInput(inputEl) {
const key = inputEl.dataset.rowKey;
const value = parseDecimalInput(inputEl.value || '');
if (value > 0) {
saleRowMarkups[key] = value;
} else {
delete saleRowMarkups[key];
}
recomputeSalePrices();
triggerAutoSave();
}
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -4849,25 +4937,27 @@ function formatUpliftInput(value) {
return value.toFixed(4).replace('.', ',');
}
// One or two decimals, comma separator, no trailing zeros — for the per-row Накидка input.
function formatMarkupInput(value) {
if (!Number.isFinite(value) || value <= 0) return '';
return String(Math.round(value * 100) / 100).replace('.', ',');
}
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;
document.querySelectorAll(`#pricing-body-${table} tr.pricing-row-${table}`).forEach(tr => {
total += parseFloat(tr.dataset.est) || 0;
});
return total;
}
// Apply custom (own) price proportionally to Ручная цена column.
// table: 'buy' | 'sale'
// Apply custom (own) price proportionally to the Buy table's Ручная цена column.
// The Sale table's own manual-price handling lives in recomputeSalePrices().
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;
const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
const estimateTotal = _getPricingEstimateTotal(table);
@@ -4886,7 +4976,7 @@ function applyCustomPrice(table) {
if (customPrice > 0 && estimateTotal > 0) {
let assigned = 0;
rows.forEach((tr, i) => {
const rowEst = parseFloat(tr.dataset[estAttr]) || 0;
const rowEst = parseFloat(tr.dataset.est) || 0;
const qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
const cell = vendorCells[i];
if (!cell) return;
@@ -4911,22 +5001,17 @@ function applyCustomPrice(table) {
const cell = vendorCells[i];
if (!cell) return;
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
if (origAttr && tr.dataset.vendorOrigUnit !== '') {
if (tr.dataset.vendorOrigUnit !== '') {
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
}
});
// 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 = '—';
}
// Recompute 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) : '—';
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
}
}
@@ -4937,7 +5022,7 @@ function onBuyCustomPriceInput() {
}
function onSaleCustomPriceInput() {
applyCustomPrice('sale');
recomputeSalePrices();
triggerAutoSave();
}
@@ -4988,20 +5073,33 @@ async function exportPricingCSV(table) {
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
const saleUplift = table === 'sale' ? PricingMarkup.getSaleUplift() : 0;
// Sale export drops Estimate/Stock/Competitor and collapses to LOT/Описание/
// Кол-во/Цена — see internal/services/export.go MinimalSaleColumns. Buy export is
// untouched.
const requestBody = table === 'sale'
? {
include_lot: true,
basis: basis,
sale_markup: saleUplift > 0 ? saleUplift : null,
manual_price: manualPrice > 0 ? manualPrice : null,
minimal_sale_columns: true,
sale_row_markups: saleRowMarkups,
}
: {
include_lot: true,
include_bom: true,
include_estimate: true,
include_stock: !!showStockPrices,
include_competitor: true,
basis: basis,
sale_markup: null,
manual_price: manualPrice > 0 ? manualPrice : null,
};
try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
include_lot: true,
include_bom: true,
include_estimate: true,
include_stock: !!showStockPrices,
include_competitor: true,
basis: basis,
sale_markup: saleUplift > 0 ? saleUplift : null,
manual_price: manualPrice > 0 ? manualPrice : null,
}),
body: JSON.stringify(requestBody),
});
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
const blob = await resp.blob();