Files
QuoteForge/web/templates/admin_pricing.html
Mikhail Chusavitin db37040399 Исправления расчёта цен и добавление функционала своей цены
- Исправлен расчёт цен: теперь учитывается метод (медиана/среднее) и период для каждого компонента
- Добавлены функции calculateMedian и calculateAverage
- Исправлен PreviewPrice для корректного предпросмотра с учётом настроек
- Сортировка по умолчанию изменена на популярность (desc)
- Добавлен раздел "Своя цена" в конфигуратор:
  - Ввод целевой цены с пропорциональным пересчётом всех позиций
  - Отображение скидки в процентах
  - Таблица скорректированных цен
  - Экспорт CSV со скидкой

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:53:39 +03:00

603 lines
26 KiB
HTML
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.
{{define "title"}}Цены - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Управление ценами</h1>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-center border-b pb-4 mb-4">
<div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="text-sm text-gray-600 mt-2">
<span id="progress-stats"></span>
</div>
</div>
<!-- Search and sort (only for components) -->
<div id="search-bar" class="mb-4 hidden">
<div class="flex gap-4 items-center">
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
class="flex-1 px-3 py-2 border rounded"
onkeyup="debounceSearch()">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">Сортировка:</span>
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
<option value="lot_name">Артикул</option>
<option value="popularity_score" selected>Популярность</option>
<option value="quote_count">Кол-во котировок</option>
<option value="current_price">Цена</option>
</select>
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm"></button>
</div>
</div>
</div>
<div id="tab-content">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
<span id="page-info" class="text-sm text-gray-600"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
</div>
</div>
</div>
</div>
<!-- Price Settings Modal -->
<div id="price-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-semibold">Настройка цены</h3>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">&times;</button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
<select id="modal-method" class="w-full px-3 py-2 border rounded">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
<select id="modal-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90">1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Коэффициент корректировки (%)</label>
<input type="number" id="modal-coefficient" step="1" class="w-full px-3 py-2 border rounded" placeholder="0">
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
</div>
<div class="border-t pt-4">
<label class="flex items-center mb-2">
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
</div>
<div class="bg-gray-50 p-3 rounded space-y-2">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-600">Последняя цена:</div>
<div id="modal-last-price" class="font-medium text-right"></div>
<div class="text-gray-600">Медиана (всё время):</div>
<div id="modal-median-all" class="font-medium text-right"></div>
<div class="text-gray-600">Текущая цена:</div>
<div id="modal-current-price" class="font-medium text-right"></div>
<div class="text-gray-600 font-medium text-blue-600">Новая цена:</div>
<div id="modal-new-price" class="font-bold text-right text-blue-600"></div>
</div>
<div class="text-xs text-gray-500 pt-2 border-t">
Кол-во котировок: <span id="modal-quote-count"></span>
</div>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button onclick="closeModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Отмена</button>
<button onclick="savePrice()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script>
let currentTab = 'alerts';
let currentPage = 1;
let totalPages = 1;
let perPage = 50;
let searchTimeout = null;
let currentSearch = '';
let componentsCache = [];
let sortField = 'popularity_score';
let sortDir = 'desc';
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('search-bar').className = tab === 'components' ? 'mb-4' : 'mb-4 hidden';
document.getElementById('pagination').className = tab === 'components' ? 'flex justify-between items-center mt-4 pt-4 border-t' : 'hidden';
await loadData();
}
async function loadData() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
try {
if (currentTab === 'alerts') {
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
renderAlerts(data.alerts || []);
} else {
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
if (sortField) {
url += '&sort=' + encodeURIComponent(sortField);
}
if (sortDir) {
url += '&dir=' + encodeURIComponent(sortDir);
}
const resp = await fetch(url, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
renderComponents(componentsCache, data.total);
updatePagination(data.total);
}
} catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
}
}
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('search-input').value;
currentPage = 1;
loadData();
}, 300);
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadData();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
loadData();
}
}
function updatePagination(total) {
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
}
function renderAlerts(alerts) {
if (alerts.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
return;
}
let html = '<div class="space-y-2">';
alerts.forEach(a => {
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
});
html += '</div>';
document.getElementById('tab-content').innerHTML = html;
}
function renderComponents(components, total) {
if (components.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Популярность</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во котировок</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
html += '</tr></thead><tbody class="divide-y">';
components.forEach((c, idx) => {
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const category = c.category ? c.category.code : '—';
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
const quoteCount = c.quote_count || 0;
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
// Build settings summary
let settings = [];
const method = c.price_method || 'median';
settings.push(method === 'median' ? 'М' : 'С');
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.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 + 'д');
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
if (c.manual_price && c.manual_price > 0) {
settings.push('РУЧН');
}
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Modal functions
function openModal(idx) {
const c = componentsCache[idx];
if (!c) return;
document.getElementById('modal-lot-name').value = c.lot_name;
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
const hasManual = c.manual_price && c.manual_price > 0;
document.getElementById('modal-manual-enabled').checked = hasManual;
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
document.getElementById('modal-manual-price').disabled = !hasManual;
// Reset price displays while loading
document.getElementById('modal-last-price').textContent = '...';
document.getElementById('modal-median-all').textContent = '...';
document.getElementById('modal-current-price').textContent = '...';
document.getElementById('modal-new-price').textContent = '...';
document.getElementById('modal-quote-count').textContent = '...';
document.getElementById('price-modal').classList.remove('hidden');
document.getElementById('price-modal').classList.add('flex');
// Fetch price preview
fetchPreview();
}
async function fetchPreview() {
const token = localStorage.getItem('token');
if (!token) return;
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
try {
const resp = await fetch('/admin/pricing/preview', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient
})
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
const data = await resp.json();
// Update last price with date
if (data.last_price) {
let lastPriceText = '$' + parseFloat(data.last_price).toFixed(2);
if (data.last_price_date) {
const date = new Date(data.last_price_date);
lastPriceText += ' (' + date.toLocaleDateString('ru-RU') + ')';
}
document.getElementById('modal-last-price').textContent = lastPriceText;
} else {
document.getElementById('modal-last-price').textContent = '—';
}
// Update median all time
document.getElementById('modal-median-all').textContent =
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
// Update current price
document.getElementById('modal-current-price').textContent =
data.current_price ? '$' + parseFloat(data.current_price).toFixed(2) : '—';
// Update new calculated price
document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
}
} catch(e) {
console.error('Preview fetch error:', e);
document.getElementById('modal-last-price').textContent = '—';
document.getElementById('modal-median-all').textContent = '—';
document.getElementById('modal-current-price').textContent = '—';
document.getElementById('modal-new-price').textContent = '—';
}
}
function closeModal() {
document.getElementById('price-modal').classList.add('hidden');
document.getElementById('price-modal').classList.remove('flex');
}
function toggleManualPrice() {
const enabled = document.getElementById('modal-manual-enabled').checked;
document.getElementById('modal-manual-price').disabled = !enabled;
if (!enabled) {
document.getElementById('modal-manual-price').value = '';
}
fetchPreview();
}
// Debounce helper for preview updates
let previewTimeout = null;
function debounceFetchPreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(fetchPreview, 300);
}
async function savePrice() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const manualEnabled = document.getElementById('modal-manual-enabled').checked;
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
const body = {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled
};
if (manualEnabled && manualPrice > 0) {
body.manual_price = manualPrice;
}
try {
const resp = await fetch('/admin/pricing/update', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
closeModal();
loadData();
} else {
const data = await resp.json();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
} catch(e) {
alert('Ошибка соединения');
}
}
function recalculateAll() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const progressPercent = document.getElementById('progress-percent');
const progressStats = document.getElementById('progress-stats');
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
fetch('/admin/pricing/recalculate-all', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token}
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
progressContainer.style.display = 'none';
if (currentTab === 'components') {
loadData();
}
}, 2000);
return;
}
const text = decoder.decode(value);
const lines = text.split('\n');
lines.forEach(line => {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.substring(5).trim());
const percent = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
} catch(e) {
console.log('Parse error:', e, line);
}
}
});
read();
});
}
read();
}).catch(e => {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressContainer.style.display = 'none';
});
}
// Close modal on click outside
document.getElementById('price-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
function changeSort() {
sortField = document.getElementById('sort-field').value;
currentPage = 1;
loadData();
}
function toggleSortDir() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
document.getElementById('sort-dir-btn').textContent = sortDir === 'asc' ? '↑' : '↓';
currentPage = 1;
loadData();
}
document.addEventListener('DOMContentLoaded', () => {
loadTab('alerts');
// Add event listeners for preview updates
document.getElementById('modal-method').addEventListener('change', fetchPreview);
document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
});
</script>
{{end}}
{{template "base" .}}