Files
PriceForge/web/static/js/pricelist_detail.js
Mikhail Chusavitin c53c484bde Replace competitor discount with price_uplift; stock pricelist detail UI
- Drop `expected_discount_pct`, add `price_uplift DECIMAL(8,4) DEFAULT 1.3`
  to `qt_competitors` (migration 040); formula: effective_price = price / uplift
- Extend `LoadLotMetrics` to return per-PN qty map (`pnQtysByLot`)
- Add virtual fields `CompetitorNames`, `PriceSpreadPct`, `PartnumberQtys`
  to `PricelistItem`; populate via `enrichWarehouseItems` / `enrichCompetitorItems`
- Competitor quotes filtered to qty > 0 before lot resolution
- New "stock layout" on pricelist detail page for warehouse/competitor:
  Partnumbers column (PN + qty, only qty>0), Поставщик column, no Настройки/Доступно
- Spread badge ±N% shown next to price for competitor rows
- Bible updated: pricelist.md, history.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:58:41 +03:00

399 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const pricelistId = window.location.pathname.split('/').pop();
let currentPage = 1;
let searchQuery = '';
let searchTimeout = null;
let currentSource = '';
async function loadPricelistInfo() {
try {
const resp = await fetch(`/api/pricelists/${pricelistId}`);
if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json();
currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version;
document.getElementById('pl-date').textContent = new Date(pl.created_at).toLocaleDateString('ru-RU');
document.getElementById('pl-author').textContent = pl.created_by || '-';
document.getElementById('pl-items').textContent = pl.item_count;
document.getElementById('pl-usage').textContent = pl.usage_count;
// Show notification if present and pricelist is active
const notificationEl = document.getElementById('pl-notification');
if (pl.notification && pl.is_active) {
notificationEl.textContent = pl.notification;
notificationEl.classList.remove('hidden');
} else {
notificationEl.classList.add('hidden');
}
const statusClass = pl.is_active ? 'text-green-600' : 'text-gray-600';
document.getElementById('pl-status').innerHTML = `<span class="${statusClass}">${pl.is_active ? 'Активен' : 'Неактивен'}</span>`;
if (pl.expires_at) {
document.getElementById('pl-expires').textContent = new Date(pl.expires_at).toLocaleDateString('ru-RU');
} else {
document.getElementById('pl-expires').textContent = '-';
}
} catch (e) {
document.getElementById('page-title').textContent = 'Ошибка';
showToast('Прайслист не найден', 'error');
}
}
async function loadItems(page = 1) {
currentPage = page;
try {
let url = `/api/pricelists/${pricelistId}/items?page=${page}&per_page=50`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const resp = await fetch(url);
const data = await resp.json();
currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
}
}
function isWarehouseSource() {
return (currentSource || '').toLowerCase() === 'warehouse';
}
function isCompetitorSource() {
return (currentSource || '').toLowerCase() === 'competitor';
}
function itemsColspan() {
if (isWarehouseSource()) return 6; // no "Доступно", no "Настройки", has "Поставщик"
if (isCompetitorSource()) return 6;
return 5;
}
function toggleWarehouseColumns() {
const warehouse = isWarehouseSource();
const competitor = isCompetitorSource();
const showStockLayout = warehouse || competitor;
document.getElementById('th-qty').classList.toggle('hidden', true); // hidden for all sources
document.getElementById('th-partnumbers').classList.toggle('hidden', !showStockLayout);
document.getElementById('th-competitors').classList.toggle('hidden', !showStockLayout); // "Поставщик" for both
document.getElementById('th-settings').classList.toggle('hidden', showStockLayout);
}
function formatQty(qty) {
if (typeof qty !== 'number') return '—';
if (Number.isInteger(qty)) return qty.toString();
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function closePriceChangesModal() {
document.getElementById('price-changes-modal').classList.add('hidden');
document.getElementById('price-changes-modal').classList.remove('flex');
}
function formatReportPrice(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
return Number(value).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function renderPriceChangesTable(items, showDiff = true) {
if (!Array.isArray(items) || items.length === 0) return '<div class="text-sm text-gray-500">Нет данных</div>';
const rows = items.map(item => {
const t = item.change_type || '';
const rowClass = t === 'increased' ? 'bg-red-50' : (t === 'decreased' ? 'bg-green-50' : '');
const diffValue = Number(item.diff || 0);
const diffClass = diffValue > 0 ? 'text-red-700' : (diffValue < 0 ? 'text-green-700' : 'text-gray-700');
const diffText = (showDiff && item.diff != null) ? `${diffValue > 0 ? '+' : ''}${formatReportPrice(diffValue)}` : '-';
return `
<tr class="${rowClass}">
<td class="px-3 py-2 font-mono text-xs">${escapeHtml(item.lot_name || '')}</td>
<td class="px-3 py-2 text-right">${formatReportPrice(item.old_price)}</td>
<td class="px-3 py-2 text-right">${item.new_price == null ? '-' : formatReportPrice(item.new_price)}</td>
<td class="px-3 py-2 text-right ${diffClass}">${diffText}</td>
</tr>
`;
}).join('');
return `
<div class="border rounded-lg overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left">Позиция</th>
<th class="px-3 py-2 text-right">Было</th>
<th class="px-3 py-2 text-right">Стало</th>
<th class="px-3 py-2 text-right">Изм.</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
async function openPriceChangesReport() {
try {
const summary = document.getElementById('price-changes-summary');
const content = document.getElementById('price-changes-content');
summary.textContent = 'Загрузка отчета...';
content.innerHTML = '';
document.getElementById('price-changes-modal').classList.remove('hidden');
document.getElementById('price-changes-modal').classList.add('flex');
const resp = await fetch(`/api/pricelists/${pricelistId}/price-changes`);
if (!resp.ok) throw new Error('Не удалось загрузить отчет');
const changes = await resp.json();
const changed = Array.isArray(changes.changed) ? changes.changed : [];
const missing = Array.isArray(changes.missing) ? changes.missing : [];
const prevVersion = changes.previous_pricelist_version || null;
summary.innerHTML = prevVersion
? `Сравнение с прайслистом <span class="font-mono">${escapeHtml(prevVersion)}</span>. Изменилось: <b>${changed.length}</b>, пропало (нет котировок): <b>${missing.length}</b>.`
: 'Предыдущий прайслист для сравнения не найден.';
content.innerHTML = `
<div>
<h3 class="font-semibold mb-2">Изменившиеся цены</h3>
${renderPriceChangesTable(changed, true)}
</div>
<div>
<h3 class="font-semibold mb-2">Пропали котировки (цена была раньше)</h3>
${renderPriceChangesTable(missing, false)}
</div>
`;
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
closePriceChangesModal();
}
}
function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (method === 'average') {
settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else {
settings.push('Мед');
}
// Period (only if not manual price)
if (!hasManualPrice) {
const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
}
// Coefficient
if (item.price_coefficient && item.price_coefficient !== 0) {
settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%');
}
// Meta article indicator
if (hasMeta) {
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
}
return settings.join(' | ') || '-';
}
function renderItems(items) {
if (items.length === 0) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-gray-500">
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
</td>
</tr>
`;
return;
}
const showWarehouse = isWarehouseSource();
const showCompetitor = isCompetitorSource();
const showStockLayout = showWarehouse || showCompetitor;
const tdPad = showStockLayout ? 'px-3 py-2' : 'px-6 py-3';
const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-';
const descLimit = showStockLayout ? 30 : 60;
const truncatedDesc = description.length > descLimit ? description.substring(0, descLimit) + '…' : description;
// Partnumbers: each as "PN (qty шт.)" lines, truncated to MAX_PN visible
let partnumbersHtml = '—';
if (showStockLayout && Array.isArray(item.partnumbers) && item.partnumbers.length > 0) {
const qtys = item.partnumber_qtys || {};
const MAX_PN = 4;
const withQty = item.partnumbers.filter(pn => qtys[pn] > 0);
const list = withQty.length > 0 ? withQty : item.partnumbers; // fallback: show all if none have qty
const formatPN = pn => {
const q = qtys[pn];
const qStr = (q != null && q > 0) ? ` <span class="text-gray-400">(${formatQty(q)} шт.)</span>` : '';
return `<span class="font-mono">${escapeHtml(pn)}</span>${qStr}`;
};
const shown = list.slice(0, MAX_PN).map(formatPN).join('<br>');
const extra = list.length > MAX_PN
? `<br><span class="text-gray-400 text-xs">+${list.length - MAX_PN} ещё</span>`
: '';
partnumbersHtml = `<div class="text-xs leading-snug">${shown}${extra}</div>`;
}
// Supplier column: competitor names or "склад"
let supplierHtml = '';
if (showCompetitor) {
supplierHtml = Array.isArray(item.competitor_names) && item.competitor_names.length > 0
? item.competitor_names.map(n => `<span class="inline-block text-xs bg-blue-50 text-blue-700 rounded px-1 mr-0.5">${escapeHtml(n)}</span>`).join('')
: '—';
} else if (showWarehouse) {
supplierHtml = '<span class="text-xs text-gray-500">склад</span>';
}
const spreadBadge = (showCompetitor && item.price_spread_pct != null)
? ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`
: '';
return `
<tr class="hover:bg-gray-50">
<td class="${tdPad} max-w-[140px] break-all">
<span class="font-mono text-sm">${item.lot_name}</span>
</td>
<td class="${tdPad} whitespace-nowrap">
<span class="px-1.5 py-0.5 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
</td>
<td class="${tdPad} text-xs text-gray-500 max-w-[160px]" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td>
${showStockLayout ? `<td class="${tdPad} text-xs text-gray-600 max-w-[200px]">${partnumbersHtml}</td>` : ''}
${showStockLayout ? `<td class="${tdPad} text-xs whitespace-nowrap">${supplierHtml}</td>` : ''}
<td class="${tdPad} whitespace-nowrap text-right font-mono text-sm">${price}${spreadBadge}</td>
${!showStockLayout ? `<td class="${tdPad} whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>` : ''}
</tr>
`;
}).join('');
document.getElementById('items-body').innerHTML = html;
}
function renderItemsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
document.getElementById('items-info').textContent = `${start}-${end} из ${total}`;
if (totalPages <= 1) {
document.getElementById('items-pages').innerHTML = '';
return;
}
let html = '';
// Previous button
if (page > 1) {
html += `<button onclick="loadItems(${page - 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&larr;</button>`;
}
// Page numbers (show max 5 pages)
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, startPage + 4);
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === page ? 'bg-orange-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadItems(${i})" class="px-3 py-1 text-sm rounded border ${activeClass}">${i}</button>`;
}
// Next button
if (page < totalPages) {
html += `<button onclick="loadItems(${page + 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&rarr;</button>`;
}
document.getElementById('items-pages').innerHTML = html;
}
document.getElementById('search-input').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = e.target.value.trim();
loadItems(1);
}, 300);
});
async function exportToCSV() {
try {
showToast('Экспорт в CSV...', 'info');
const resp = await fetch(`/api/pricelists/${pricelistId}/export-csv`);
if (!resp.ok) {
throw new Error('Ошибка экспорта');
}
// Get filename from Content-Disposition header
const contentDisposition = resp.headers.get('Content-Disposition');
let filename = `pricelist_${pricelistId}.csv`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (matches && matches[1]) {
filename = matches[1];
}
}
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
setTimeout(() => {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 100);
showToast('CSV файл экспортирован', 'success');
} catch (e) {
showToast('Ошибка экспорта: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', function() {
loadPricelistInfo();
loadItems(1);
});