Files
PriceForge/web/static/js/pricelists.js
Mikhail Chusavitin f48615e8a9 Modularize Go files, extract JS to static, implement competitor pricelists
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>
2026-03-13 07:44:10 +03:00

261 lines
12 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.
let canWrite = false;
let currentPage = 1;
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
async function checkPricelistWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
canWrite = data.can_write;
if (canWrite) {
document.getElementById('create-btn-container').innerHTML = `
<button onclick="openCreateModal()" class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700">
Создать прайслист
</button>
`;
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
}
}
async function loadPricelists(page = 1) {
currentPage = page;
try {
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json();
renderPricelists(data.pricelists || []);
renderPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${escapeHtml(e.message)}
</td>
</tr>
`;
}
}
function renderPricelists(pricelists) {
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
`;
return;
}
const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
const id = Number(pl.id);
const safeID = Number.isFinite(id) ? id : 0;
const safeVersion = escapeHtml(pl.version || '');
const safeDate = escapeHtml(date);
const safeCreatedBy = escapeHtml(pl.created_by || '-');
const safeItemCount = Number.isFinite(Number(pl.item_count)) ? Number(pl.item_count) : 0;
const safeUsageCount = Number.isFinite(Number(pl.usage_count)) ? Number(pl.usage_count) : 0;
let actions = `<a href="/pricelists/${safeID}" class="text-orange-600 hover:text-orange-800 text-sm">Просмотр</a>`;
if (canWrite && safeUsageCount === 0 && safeID > 0) {
actions += ` <button onclick="deletePricelist(${safeID})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${safeVersion}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${safeDate}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${safeCreatedBy}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${safeItemCount}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${safeUsageCount}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
</tr>
`;
}).join('');
document.getElementById('pricelists-body').innerHTML = html;
}
function renderPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
document.getElementById('pagination').innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-orange-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
}
document.getElementById('pagination').innerHTML = html;
}
async function loadDbUsername() {
try {
const resp = await fetch('/api/current-user');
const data = await resp.json();
document.getElementById('db-username').textContent = data.username || 'неизвестно';
} catch (e) {
document.getElementById('db-username').textContent = 'неизвестно';
}
}
function openCreateModal() {
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
loadDbUsername();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
function closePriceChangesModal() {
document.getElementById('price-changes-modal').classList.add('hidden');
document.getElementById('price-changes-modal').classList.remove('flex');
}
function formatPrice(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
return Number(value).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function renderPriceChangesTable(items, { showOldPrice = true, showNewPrice = true, showDiff = true } = {}) {
if (!items || items.length === 0) return '<div class="text-sm text-gray-500">Нет данных</div>';
const rows = items.map(item => {
const changeType = item.change_type || '';
const rowClass = changeType === 'increased'
? 'bg-red-50'
: (changeType === 'decreased' ? 'bg-green-50' : (changeType === 'added' ? 'bg-blue-50' : ''));
const diffValue = Number(item.diff || 0);
const diffText = showDiff && item.diff !== null && item.diff !== undefined
? `${diffValue > 0 ? '+' : ''}${formatPrice(diffValue)}`
: '-';
const diffClass = diffValue > 0 ? 'text-red-700' : (diffValue < 0 ? 'text-green-700' : 'text-gray-700');
return `
<tr class="${rowClass}">
<td class="px-3 py-2 font-mono text-xs">${escapeHtml(item.lot_name || '')}</td>
${showOldPrice ? `<td class="px-3 py-2 text-right">${formatPrice(item.old_price)}</td>` : ''}
${showNewPrice ? `<td class="px-3 py-2 text-right">${item.new_price == null ? '-' : formatPrice(item.new_price)}</td>` : ''}
${showDiff ? `<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>
${showOldPrice ? '<th class="px-3 py-2 text-right">Было</th>' : ''}
${showNewPrice ? '<th class="px-3 py-2 text-right">Стало</th>' : ''}
${showDiff ? '<th class="px-3 py-2 text-right">Изм.</th>' : ''}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function openPriceChangesModal(pl) {
const changes = pl?.price_changes || {};
const changed = Array.isArray(changes.changed) ? changes.changed : [];
const missing = Array.isArray(changes.missing) ? changes.missing : [];
const added = Array.isArray(changes.added) ? changes.added : [];
const prevVersion = changes.previous_pricelist_version || null;
document.getElementById('price-changes-summary').innerHTML = prevVersion
? `Сравнение с прайслистом <span class="font-mono">${escapeHtml(prevVersion)}</span>. Изменилось: <b>${changed.length}</b>, пропало: <b>${missing.length}</b>, добавлено: <b>${added.length}</b>.`
: 'Предыдущий прайслист для сравнения не найден.';
let html = '';
html += `<div><h3 class="font-semibold mb-2">Изменившиеся цены</h3>${renderPriceChangesTable(changed)}</div>`;
html += `<div><h3 class="font-semibold mb-2">Пропали котировки (цена была раньше)</h3>${renderPriceChangesTable(missing, { showNewPrice: true, showDiff: false })}</div>`;
html += `<div><h3 class="font-semibold mb-2">Новые позиции</h3>${renderPriceChangesTable(added, { showOldPrice: false, showNewPrice: true, showDiff: false })}</div>`;
document.getElementById('price-changes-content').innerHTML = html;
document.getElementById('price-changes-modal').classList.remove('hidden');
document.getElementById('price-changes-modal').classList.add('flex');
}
async function createPricelist() {
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
}
async function deletePricelist(id) {
if (!confirm('Удалить этот прайслист?')) return;
try {
const resp = await fetch(`/api/pricelists/${id}`, {
method: 'DELETE'
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to delete');
}
showToast('Прайслист удален', 'success');
loadPricelists(currentPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.getElementById('create-form').addEventListener('submit', async function(e) {
e.preventDefault();
try {
const pl = await createPricelist();
closeCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
openPriceChangesModal(pl);
loadPricelists(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
document.addEventListener('DOMContentLoaded', function() {
checkPricelistWritePermission();
loadPricelists(1);
});