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:
2026-02-21 22:16:16 +03:00
parent 225e1beda9
commit a4457a0a28
13 changed files with 751 additions and 32 deletions

View File

@@ -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>