Add partnumber book snapshots for QuoteForge integration
- Migrations 026-028: qt_partnumber_books + qt_partnumber_book_items tables; is_primary_pn on lot_partnumbers; version VARCHAR(30); description VARCHAR(10000) on items (required by QuoteForge sync) - Service: CreateSnapshot expands bundles, filters empty lot_name and ignored PNs, copies description, activates new book atomically, applies GFS retention (7d/5w/12m/10y) with explicit item deletion - Task type TaskTypePartnumberBookCreate; handlers ListPartnumberBooks and CreatePartnumberBook; routes GET/POST /api/admin/pricing/partnumber-books - UI: snapshot list + "Создать снапшот сопоставлений" button with progress polling on /vendor-mappings page - Bible: history, api, background-tasks, vendor-mapping updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,48 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Глобальные сопоставления Vendor Partnumbers</h1>
|
||||
|
||||
<!-- Partnumber Book Snapshots -->
|
||||
<div class="bg-white rounded-lg shadow p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-800">Снимки сопоставлений (Partnumber Books)</h2>
|
||||
<button onclick="createPartnumberBook()" id="btn-create-book" class="px-3 py-2 bg-orange-600 text-white rounded hover:bg-orange-700 text-sm">
|
||||
Создать снапшот сопоставлений
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for book creation -->
|
||||
<div id="book-progress-container" class="hidden p-3 bg-orange-50 rounded border border-orange-200">
|
||||
<div class="flex justify-between text-sm text-gray-700 mb-1">
|
||||
<span id="book-progress-text" class="font-medium">Создание снимка...</span>
|
||||
<span id="book-progress-percent" class="font-bold">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div id="book-progress-bar" class="bg-orange-600 h-3 rounded-full transition-all duration-300" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="book-error" class="hidden p-2 rounded bg-red-50 text-red-700 text-sm"></div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Строк</th>
|
||||
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="book-list" class="bg-white divide-y divide-gray-200">
|
||||
<tr><td colspan="5" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400">Политика хранения: 7 ежедневных · 5 еженедельных · 12 ежемесячных · 10 ежегодных. Старые снимки удаляются автоматически при создании нового.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4 space-y-4">
|
||||
<div class="flex gap-3 items-center">
|
||||
<input id="vm-search" type="text" placeholder="Поиск по vendor / partnumber / LOT / описанию" class="flex-1 px-3 py-2 border rounded">
|
||||
@@ -352,6 +394,103 @@ async function deleteVendorMapping() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Partnumber Book Snapshots ────────────────────────────────────────────────
|
||||
|
||||
async function loadPartnumberBooks() {
|
||||
const tbody = document.getElementById('book-list');
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/partnumber-books');
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
|
||||
|
||||
const books = data.books || [];
|
||||
if (books.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-3 text-sm text-gray-500">Снимков пока нет</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = books.map(b => {
|
||||
const date = new Date(b.created_at).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||||
const status = b.is_active
|
||||
? '<span class="px-2 py-0.5 bg-green-100 text-green-800 rounded text-xs font-medium">активный</span>'
|
||||
: '<span class="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">архив</span>';
|
||||
return `<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 font-mono text-xs">${escapeHtml(b.version)}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600">${date}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(b.created_by || '—')}</td>
|
||||
<td class="px-4 py-2 text-sm text-right">${Number(b.item_count).toLocaleString('ru-RU')}</td>
|
||||
<td class="px-4 py-2 text-center">${status}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setBookError(msg) {
|
||||
const box = document.getElementById('book-error');
|
||||
if (!msg) { box.classList.add('hidden'); box.textContent = ''; return; }
|
||||
box.classList.remove('hidden');
|
||||
box.textContent = msg;
|
||||
}
|
||||
|
||||
async function createPartnumberBook() {
|
||||
const btn = document.getElementById('btn-create-book');
|
||||
const progressContainer = document.getElementById('book-progress-container');
|
||||
const progressBar = document.getElementById('book-progress-bar');
|
||||
const progressText = document.getElementById('book-progress-text');
|
||||
const progressPercent = document.getElementById('book-progress-percent');
|
||||
|
||||
setBookError('');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Создание...';
|
||||
progressContainer.classList.remove('hidden');
|
||||
progressBar.style.width = '0%';
|
||||
progressText.textContent = 'Запуск задачи...';
|
||||
progressPercent.textContent = '0%';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/partnumber-books', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Ошибка запуска задачи');
|
||||
|
||||
const taskId = data.task_id;
|
||||
|
||||
// Poll until done
|
||||
for (;;) {
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
const tr = await fetch(`/api/tasks/${taskId}`);
|
||||
if (!tr.ok) break;
|
||||
const task = await tr.json();
|
||||
|
||||
const pct = task.progress || 0;
|
||||
progressBar.style.width = pct + '%';
|
||||
progressPercent.textContent = pct + '%';
|
||||
progressText.textContent = task.message || 'Создание снимка...';
|
||||
|
||||
if (task.status === 'completed') {
|
||||
progressBar.style.width = '100%';
|
||||
progressPercent.textContent = '100%';
|
||||
progressText.textContent = task.message || 'Готово';
|
||||
break;
|
||||
}
|
||||
if (task.status === 'error') {
|
||||
throw new Error(task.error || 'Ошибка при создании снимка');
|
||||
}
|
||||
}
|
||||
|
||||
await loadPartnumberBooks();
|
||||
} catch (e) {
|
||||
setBookError(e.message || 'Неизвестная ошибка');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Создать снапшот сопоставлений';
|
||||
setTimeout(() => progressContainer.classList.add('hidden'), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Vendor Mappings ──────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('vm-search').addEventListener('input', () => {
|
||||
clearTimeout(vmSearchTimer);
|
||||
@@ -371,6 +510,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
loadPartnumberBooks();
|
||||
loadVendorMappings(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user