Go refactoring: - Split handlers/pricing.go (2446→291 lines) into 5 focused files - Split services/stock_import.go (1334→~400 lines) into stock_mappings.go + stock_parse.go - Split services/sync/service.go (1290→~250 lines) into 3 files JS extraction: - Move all inline <script> blocks to web/static/js/ (6 files) - Templates reduced: admin_pricing 2873→521, lot 1531→304, vendor_mappings 1063→169, etc. Competitor pricelists (migrations 033-039): - qt_competitors + partnumber_log_competitors tables - Excel import with column mapping, dedup, bulk insert - p/n→lot resolution via weighted_median, discount applied - Unmapped p/ns written to qt_vendor_partnumber_seen - Quote counts (unique/total) shown on /admin/competitors - price_method="weighted_median", price_period_days=0 stored explicitly Fix price_method/price_period_days for warehouse items: - warehouse: weighted_avg, period=0 - competitor: weighted_median, period=0 - Removes misleading DB defaults (was: median/90) Update bible: architecture.md, pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
354 lines
15 KiB
JavaScript
354 lines
15 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 itemsColspan() {
|
||
return isWarehouseSource() ? 7 : 5;
|
||
}
|
||
|
||
function toggleWarehouseColumns() {
|
||
const visible = isWarehouseSource();
|
||
document.getElementById('th-qty').classList.toggle('hidden', !visible);
|
||
document.getElementById('th-partnumbers').classList.toggle('hidden', !visible);
|
||
}
|
||
|
||
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 html = items.map(item => {
|
||
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
const description = item.lot_description || '-';
|
||
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
|
||
const qty = formatQty(item.available_qty);
|
||
const partnumbers = Array.isArray(item.partnumbers) && item.partnumbers.length > 0 ? item.partnumbers.join(', ') : '—';
|
||
|
||
return `
|
||
<tr class="hover:bg-gray-50">
|
||
<td class="px-6 py-3 whitespace-nowrap">
|
||
<span class="font-mono text-sm">${item.lot_name}</span>
|
||
</td>
|
||
<td class="px-6 py-3 whitespace-nowrap">
|
||
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
|
||
</td>
|
||
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
|
||
${showWarehouse ? `<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${qty}</td>` : ''}
|
||
${showWarehouse ? `<td class="px-6 py-3 text-sm text-gray-600" title="${escapeHtml(partnumbers)}">${escapeHtml(partnumbers)}</td>` : ''}
|
||
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
|
||
<td class="px-6 py-3 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);
|
||
});
|