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

@@ -63,6 +63,27 @@
</div>
</div>
<!-- Top-level tabs: Estimate | BOM вендора | Ценообразование -->
<div class="bg-white rounded-lg shadow mb-0">
<nav class="flex border-b">
<button id="top-tab-estimate" onclick="switchTopTab('estimate')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-blue-600 text-blue-600">
Estimate
</button>
<button id="top-tab-bom" onclick="switchTopTab('bom')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
BOM вендора
</button>
<button id="top-tab-pricing" onclick="switchTopTab('pricing')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
Ценообразование
</button>
</nav>
</div>
<!-- Top-tab section: Estimate -->
<div id="top-section-estimate">
<!-- Category Tabs -->
<div class="bg-white rounded-lg shadow">
<div class="border-b">
@@ -229,6 +250,103 @@
</div>
</div>
</div>
</div><!-- end top-section-estimate -->
<!-- Top-tab section: BOM вендора -->
<div id="top-section-bom" class="hidden">
<div class="bg-white rounded-lg shadow p-4">
<div class="mb-4 flex items-center gap-3">
<span class="text-sm text-gray-600">Вставьте таблицу из Excel (Ctrl+V в область ниже):</span>
</div>
<div id="bom-paste-area"
contenteditable="true"
tabindex="0"
class="border-2 border-dashed border-gray-300 rounded-lg p-4 min-h-16 text-gray-400 focus:outline-none focus:border-blue-400 cursor-text mb-4"
onpaste="handleBOMPaste(event)"
placeholder="Нажмите сюда и вставьте из Excel (Ctrl+V)...">
Нажмите сюда и вставьте из Excel (Ctrl+V)...
</div>
<!-- BOM table -->
<div id="bom-table-container" class="hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-right border-b">Цена ед.</th>
<th class="px-3 py-2 text-right border-b">Итого</th>
<th class="px-3 py-2 text-left border-b">LOT</th>
</tr>
</thead>
<tbody id="bom-table-body"></tbody>
</table>
</div>
<div class="mt-3 flex items-center justify-between text-sm text-gray-600">
<div id="bom-stats"></div>
<div class="flex gap-2">
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
Сохранить BOM
</button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Пересчитать эстимейт
</button>
</div>
</div>
</div>
</div>
</div><!-- end top-section-bom -->
<!-- Top-tab section: Ценообразование -->
<div id="top-section-pricing" class="hidden">
<div class="bg-white rounded-lg shadow p-4">
<div id="pricing-table-container">
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Цена вендора</th>
<th class="px-3 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th>
<th class="px-3 py-2 text-right border-b">Конк.</th>
</tr>
</thead>
<tbody id="pricing-table-body">
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>
</tbody>
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="3" class="px-3 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right" id="pricing-total-vendor"></td>
<td class="px-3 py-2 text-right" id="pricing-total-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-warehouse"></td>
<td class="px-3 py-2 text-right"></td>
</tr>
</tfoot>
</table>
</div>
<div class="mt-4 flex items-center gap-4">
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onPricingCustomPriceInput()">
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
= Сумма цен вендора
</button>
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
</span>
</div>
</div>
</div>
</div><!-- end top-section-pricing -->
</div>
<!-- Price settings modal -->
@@ -787,6 +905,11 @@ document.addEventListener('DOMContentLoaded', async function() {
hideAutocomplete();
}
});
// Load vendor spec BOM for this configuration
if (configUUID) {
loadVendorSpec(configUUID);
}
});
async function loadAllComponents() {
@@ -1775,6 +1898,7 @@ function removeFromCart(lotName) {
}
function updateCartUI() {
window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2486,6 +2610,415 @@ function updatePriceUpdateDate(dateStr) {
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
}
// ==================== TOP-LEVEL TABS ====================
let currentTopTab = 'estimate';
function switchTopTab(tab) {
currentTopTab = tab;
const tabs = ['estimate', 'bom', 'pricing'];
tabs.forEach(t => {
const btn = document.getElementById('top-tab-' + t);
const section = document.getElementById('top-section-' + t);
if (t === tab) {
btn.classList.remove('border-transparent', 'text-gray-500');
btn.classList.add('border-blue-600', 'text-blue-600');
section.classList.remove('hidden');
} else {
btn.classList.remove('border-blue-600', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
section.classList.add('hidden');
}
});
if (tab === 'pricing') {
renderPricingTab();
}
}
// ==================== BOM ВЕНДОРА ====================
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
function handleBOMPaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
if (!text) return;
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
const parsed = [];
for (let i = 0; i < lines.length; i++) {
const cols = lines[i].split('\t');
if (cols.length < 2) continue;
// Auto-detect columns:
// 2 cols → [PN, qty]
// 3+ cols → first text=PN, first numeric=qty, subsequent numeric=prices
let pn = '', qty = 0, description = '', unit_price = null, total_price = null;
if (cols.length === 2) {
pn = cols[0].trim();
qty = parseInt(cols[1]) || 0;
// Skip header row if qty is NaN
if (!qty && i === 0) continue;
} else {
pn = cols[0].trim();
// Find first numeric column for qty
let qtyIdx = -1, priceIdx = -1;
for (let c = 1; c < cols.length; c++) {
const v = cols[c].trim().replace(/[, ]/g, '');
if (!isNaN(parseFloat(v)) && v !== '') {
if (qtyIdx === -1) { qtyIdx = c; }
else if (priceIdx === -1) { priceIdx = c; }
}
}
// If first row has non-numeric qty → likely header, skip
if (qtyIdx === -1) continue;
const rawQty = cols[qtyIdx].trim().replace(/[, ]/g, '');
if (i === 0 && isNaN(parseInt(rawQty))) continue;
qty = parseInt(rawQty) || 1;
// Description: columns between PN and first numeric
const descParts = [];
for (let c = 1; c < qtyIdx; c++) { descParts.push(cols[c].trim()); }
description = descParts.join(' ').trim();
if (priceIdx !== -1) {
const rawPrice = cols[priceIdx].trim().replace(/[, ]/g, '');
unit_price = parseFloat(rawPrice) || null;
if (unit_price && qty) total_price = unit_price * qty;
}
}
if (!pn) continue;
parsed.push({
sort_order: (parsed.length + 1) * 10,
vendor_pn: pn,
quantity: qty || 1,
description,
unit_price,
total_price,
resolved_lot: '',
resolution_source: 'unresolved',
manual_lot: ''
});
}
if (!parsed.length) {
alert('Не удалось распознать данные. Убедитесь, что скопированы строки из Excel.');
return;
}
bomRows = parsed;
resolveBOM();
}
async function resolveBOM() {
if (!configUUID) return;
const specPayload = bomRows.map(r => ({
sort_order: r.sort_order,
vendor_partnumber: r.vendor_pn,
quantity: r.quantity,
description: r.description,
unit_price: r.unit_price,
total_price: r.total_price,
manual_lot_suggestion: r.manual_lot
}));
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/resolve`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_spec: specPayload})
});
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
// Merge resolution results back into bomRows
if (data.resolved) {
data.resolved.forEach((r, i) => {
if (bomRows[i]) {
bomRows[i].resolved_lot = r.resolved_lot_name || '';
bomRows[i].resolution_source = r.resolution_source || 'unresolved';
}
});
}
} catch (e) {
console.warn('Resolution failed:', e);
}
renderBOMTable();
}
function renderBOMTable() {
const tbody = document.getElementById('bom-table-body');
const cart = window._currentCart || [];
// Build cart map: lot_name → quantity
const cartMap = {};
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
let unresolved = 0, mismatches = 0;
tbody.innerHTML = '';
bomRows.forEach((row, idx) => {
const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
const notInCart = row.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++;
let rowClass = '';
if (isUnresolved) rowClass = 'bg-red-50';
else if (qtyMismatch) rowClass = 'bg-yellow-50';
else if (notInCart) rowClass = 'bg-orange-50';
tr.className = rowClass;
let lotCell = '';
if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${row.manual_lot || ''}"
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
oninput="bomRows[${idx}].manual_lot = this.value; debouncedResolveBOM();"
list="lot-autocomplete-list">`;
} else {
let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
else if (notInCart) suffix = ` <span class="text-orange-500 text-xs">новый</span>`;
lotCell = `<span class="font-mono text-xs">${row.resolved_lot}</span>${suffix}`;
}
tr.innerHTML = `
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-gray-600 text-xs">${escapeHtml(row.description || '')}</td>
<td class="px-3 py-1.5 text-right text-xs">${row.unit_price != null ? formatCurrency(row.unit_price) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${row.total_price != null ? formatCurrency(row.total_price) : '—'}</td>
<td class="px-3 py-1.5">${lotCell}</td>
`;
tbody.appendChild(tr);
});
// Stats
const statsEl = document.getElementById('bom-stats');
statsEl.textContent = `Строк: ${bomRows.length} | Не сопоставлено: ${unresolved} | Расхождений: ${mismatches}`;
document.getElementById('bom-table-container').classList.remove('hidden');
// Also update pricing tab if visible
if (currentTopTab === 'pricing') renderPricingTab();
}
let _resolveBOMTimer = null;
function debouncedResolveBOM() {
clearTimeout(_resolveBOMTimer);
_resolveBOMTimer = setTimeout(() => resolveBOM(), 500);
}
async function saveBOM() {
if (!configUUID || !bomRows.length) return;
const spec = bomRows.map(r => ({
sort_order: r.sort_order,
vendor_partnumber: r.vendor_pn,
quantity: r.quantity,
description: r.description,
unit_price: r.unit_price,
total_price: r.total_price,
resolved_lot_name: r.resolved_lot,
resolution_source: r.resolution_source,
manual_lot_suggestion: r.manual_lot || null
}));
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_spec: spec})
});
if (!resp.ok) throw new Error(await resp.text());
showToast('BOM сохранён', 'success');
} catch (e) {
showToast('Ошибка сохранения BOM: ' + e.message, 'error');
}
}
async function applyBOMToEstimate() {
if (!bomRows.length) return;
const resolved = bomRows.filter(r => r.resolved_lot);
if (!resolved.length) {
alert('Нет сопоставленных строк. Сначала настройте LOT для всех позиций.');
return;
}
// Aggregate quantities
const lotMap = {};
resolved.forEach(r => {
if (!lotMap[r.resolved_lot]) lotMap[r.resolved_lot] = 0;
lotMap[r.resolved_lot] += r.quantity;
});
const items = Object.entries(lotMap).map(([lot, qty]) => ({
lot_name: lot,
quantity: qty,
unit_price: 0
}));
if (!confirm(`Пересчитать Estimate? Текущая корзина будет заменена ${items.length} позициями из BOM.`)) return;
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/apply`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items})
});
if (!resp.ok) throw new Error(await resp.text());
showToast('Estimate обновлён из BOM', 'success');
// Reload the page to show updated estimate
window.location.reload();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
// Load existing BOM on config load
async function loadVendorSpec(configUUID) {
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`);
if (!resp.ok) return;
const data = await resp.json();
if (data.vendor_spec && data.vendor_spec.length) {
bomRows = data.vendor_spec.map((r, i) => ({
sort_order: r.sort_order || (i + 1) * 10,
vendor_pn: r.vendor_partnumber,
quantity: r.quantity,
description: r.description || '',
unit_price: r.unit_price || null,
total_price: r.total_price || null,
resolved_lot: r.resolved_lot_name || '',
resolution_source: r.resolution_source || 'unresolved',
manual_lot: r.manual_lot_suggestion || ''
}));
renderBOMTable();
}
} catch (e) {
console.warn('Failed to load vendor spec:', e);
}
}
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
function renderPricingTab() {
const tbody = document.getElementById('pricing-table-body');
const tfoot = document.getElementById('pricing-table-foot');
if (!bomRows.length) {
tbody.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>';
tfoot.classList.add('hidden');
return;
}
const cart = window._currentCart || [];
const cartMap = {};
cart.forEach(item => { cartMap[item.lot_name] = item; });
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0;
let hasVendor = false, hasEstimate = false, hasWarehouse = false;
tbody.innerHTML = '';
bomRows.forEach(row => {
const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartItem = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
const estimatePrice = cartItem ? cartItem.unit_price : null;
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
if (estimatePrice != null) { totalEstimate += estimatePrice * row.quantity; hasEstimate = true; }
tr.innerHTML = `
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
<td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimatePrice != null ? formatCurrency(estimatePrice * row.quantity) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
`;
tbody.appendChild(tr);
});
// Totals row
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
document.getElementById('pricing-total-warehouse').textContent = '—';
tfoot.classList.remove('hidden');
// Update custom price discount info
onPricingCustomPriceInput();
}
function setPricingCustomPriceFromVendor() {
let totalVendor = 0;
bomRows.forEach(r => {
const vt = r.total_price != null ? r.total_price : (r.unit_price != null ? r.unit_price * r.quantity : 0);
totalVendor += vt;
});
document.getElementById('pricing-custom-price').value = totalVendor.toFixed(2);
onPricingCustomPriceInput();
}
function onPricingCustomPriceInput() {
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
// Compute estimate total
const cart = window._currentCart || [];
let estimateTotal = 0;
cart.forEach(item => { estimateTotal += (item.unit_price || 0) * item.quantity; });
const discountEl = document.getElementById('pricing-discount-info');
const pctEl = document.getElementById('pricing-discount-pct');
if (customPrice > 0 && estimateTotal > 0) {
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
pctEl.textContent = discount + '%';
discountEl.classList.remove('hidden');
} else {
discountEl.classList.add('hidden');
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatCurrency(val) {
if (val == null) return '—';
return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
// ==================== AUTOCOMPLETE LIST FOR BOM ====================
// Inject datalist for lot autocomplete in BOM inline inputs
(function() {
const dl = document.createElement('datalist');
dl.id = 'lot-autocomplete-list';
document.body.appendChild(dl);
// Populate datalist once allComponents is available
function populateLotDatalist() {
if (!window.allComponents || !window.allComponents.length) return;
dl.innerHTML = '';
window.allComponents.forEach(c => {
const opt = document.createElement('option');
opt.value = c.lot_name;
dl.appendChild(opt);
});
}
// Try after a short delay (components load async)
setTimeout(populateLotDatalist, 2000);
})();
</script>
{{end}}