- 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>
399 lines
18 KiB
JavaScript
399 lines
18 KiB
JavaScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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">←</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">→</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);
|
||
});
|