feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)

- Migration 029: local_partnumber_books, local_partnumber_book_items,
  vendor_spec TEXT column on local_configurations
- Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec,
  VendorSpecItem with JSON Valuer/Scanner
- Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber,
  SaveBook/Items, ListBooks, CountBookItems)
- Service: VendorSpecResolver 3-step resolution (book → manual suggestion
  → unresolved) + AggregateLOTs with is_primary_pn qty logic
- Sync: PullPartnumberBooks append-only pull from qt_partnumber_books
- Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler
- Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books,
  /api/sync/partnumber-books, /partnumber-books page
- UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste,
  PN resolution, inline LOT autocomplete, pricing table
- Bible: 03-database.md updated, 09-vendor-spec.md added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 10:22:22 +03:00
parent e5b6902c9e
commit 5e56f386cc
14 changed files with 1492 additions and 2 deletions

View File

@@ -0,0 +1,139 @@
{{define "title"}}QuoteForge - Листы сопоставлений{{end}}
{{define "content"}}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Листы сопоставлений PN → LOT</h1>
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Синхронизировать
</button>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<div id="books-list-loading" class="p-8 text-center text-gray-400">Загрузка...</div>
<table id="books-table" class="w-full text-sm hidden">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="px-4 py-3 text-left">Версия</th>
<th class="px-4 py-3 text-left">Дата</th>
<th class="px-4 py-3 text-right">Позиций</th>
<th class="px-4 py-3 text-center">Статус</th>
<th class="px-4 py-3 text-center">Действия</th>
</tr>
</thead>
<tbody id="books-table-body"></tbody>
</table>
<div id="books-empty" class="hidden p-8 text-center text-gray-400">
Нет листов сопоставлений. Синхронизируйте с сервером.
</div>
</div>
<!-- Book detail -->
<div id="book-detail" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between">
<h2 class="font-semibold text-gray-800">Позиции листа: <span id="detail-version"></span></h2>
<button onclick="closeBookDetail()" class="text-gray-400 hover:text-gray-700"></button>
</div>
<div class="p-4">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="px-3 py-2 text-left">Partnumber</th>
<th class="px-3 py-2 text-left">LOT</th>
<th class="px-3 py-2 text-center">Primary PN</th>
<th class="px-3 py-2 text-left">Описание</th>
</tr>
</thead>
<tbody id="detail-items-body"></tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
async function loadBooks() {
const resp = await fetch('/api/partnumber-books');
const data = await resp.json();
const books = data.books || [];
document.getElementById('books-list-loading').classList.add('hidden');
if (!books.length) {
document.getElementById('books-empty').classList.remove('hidden');
return;
}
const tbody = document.getElementById('books-table-body');
tbody.innerHTML = '';
books.forEach(b => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = `
<td class="px-4 py-3 font-mono text-sm">${b.version}</td>
<td class="px-4 py-3 text-gray-600">${b.created_at}</td>
<td class="px-4 py-3 text-right">${b.item_count}</td>
<td class="px-4 py-3 text-center">
${b.is_active ? '<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Активный</span>' : '<span class="px-2 py-1 bg-gray-100 text-gray-500 rounded text-xs">Архив</span>'}
</td>
<td class="px-4 py-3 text-center">
<button onclick="viewBookItems(${b.server_id}, '${b.version}')" class="text-blue-600 hover:text-blue-800 text-sm underline">
Просмотр
</button>
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('books-table').classList.remove('hidden');
}
async function viewBookItems(serverId, version) {
const resp = await fetch(`/api/partnumber-books/${serverId}`);
const data = await resp.json();
const items = data.items || [];
document.getElementById('detail-version').textContent = version;
const tbody = document.getElementById('detail-items-body');
tbody.innerHTML = '';
items.forEach(item => {
const tr = document.createElement('tr');
tr.className = 'border-b';
tr.innerHTML = `
<td class="px-3 py-1.5 font-mono text-xs">${item.partnumber}</td>
<td class="px-3 py-1.5 text-xs">${item.lot_name}</td>
<td class="px-3 py-1.5 text-center">${item.is_primary_pn ? '✓' : ''}</td>
<td class="px-3 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
`;
tbody.appendChild(tr);
});
document.getElementById('book-detail').classList.remove('hidden');
document.getElementById('book-detail').scrollIntoView({behavior: 'smooth'});
}
function closeBookDetail() {
document.getElementById('book-detail').classList.add('hidden');
}
async function syncPartnumberBooks() {
try {
const resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
const data = await resp.json();
if (data.success) {
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
loadBooks();
} else {
showToast('Ошибка: ' + data.error, 'error');
}
} catch (e) {
showToast('Ошибка синхронизации', 'error');
}
}
document.addEventListener('DOMContentLoaded', loadBooks);
</script>
{{end}}
{{template "base" .}}