feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- BOM paste: auto-detect columns by content (price, qty, PN, description); handles $5,114.00 and European comma-decimal formats - LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents; oninput updates data only (no re-render), onchange validates+resolves - BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string (GORM Update does not reliably call driver.Valuer for custom types) - BOM autosave after every resolveBOM() call - Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all resolved LOTs directly — Estimate prices shown even before cart apply - Unresolved PNs pushed to qt_vendor_partnumber_seen via POST /api/sync/partnumber-seen (fire-and-forget from JS) - sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at - partnumber_books: pull ALL books (not only is_active=1); re-pull items when header exists but item count is 0; fallback for missing description column - partnumber_books UI: collapsible snapshot section (collapsed by default), pagination (10/page), sync button always visible in header - vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed original_username from WHERE — GetUsername returns "" without JWT) - bible/09-vendor-spec.md: updated with all architectural decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -143,113 +143,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom price section -->
|
||||
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleCustomPriceSection()"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||||
<span class="font-semibold">Своя цена</span>
|
||||
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="custom-price-content" class="p-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">$</span>
|
||||
<input type="number" id="custom-price-input" step="0.01" min="0"
|
||||
placeholder="0.00"
|
||||
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="calculateCustomPrice(); triggerAutoSave();">
|
||||
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discount-info" class="text-right hidden">
|
||||
<div class="text-sm text-gray-600">Скидка</div>
|
||||
<div class="text-2xl font-bold text-green-600" id="discount-percent">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adjusted prices table -->
|
||||
<div id="adjusted-prices" class="hidden">
|
||||
<div class="border-t pt-3">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Скорректированные цены</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Компонент</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Было</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Стало</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Итого</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adjusted-prices-body" class="divide-y"></tbody>
|
||||
<tfoot class="bg-gray-50 font-medium">
|
||||
<tr>
|
||||
<td class="px-3 py-2" colspan="2">Итого</td>
|
||||
<td class="px-3 py-2 text-right" id="adjusted-total-original">$0.00</td>
|
||||
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-new">$0.00</td>
|
||||
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-final">$0.00</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button onclick="exportCSVWithCustomPrice()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Экспорт CSV со скидкой
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sale price section -->
|
||||
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleSalePriceSection()"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||||
<span class="font-semibold">Цена продажи</span>
|
||||
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="sale-price-content" class="p-4">
|
||||
<div id="sale-prices" class="border-t pt-3">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sale-prices-body" class="divide-y"></tbody>
|
||||
<tfoot class="bg-gray-50 font-medium">
|
||||
<tr>
|
||||
<td class="px-3 py-2">Итого</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hidden inputs kept for JS compatibility -->
|
||||
<input type="hidden" id="custom-price-input" value="">
|
||||
<div id="adjusted-prices" class="hidden"></div>
|
||||
<div id="discount-info" class="hidden"></div>
|
||||
<div id="sale-prices" class="hidden"></div>
|
||||
|
||||
</div><!-- end top-section-estimate -->
|
||||
|
||||
@@ -259,20 +157,9 @@
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">Вставьте таблицу из Excel (Ctrl+V в область ниже)</p>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-500 space-y-1">
|
||||
<p class="font-medium text-gray-600">Колонки строго по порядку:</p>
|
||||
<p>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">PN</span>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">Кол-во</span>
|
||||
<span class="text-gray-400 italic"> — минимальный</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">PN</span>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">Кол-во</span>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">Описание</span>
|
||||
<span class="font-mono bg-white border border-gray-200 rounded px-1">Цена ед.</span>
|
||||
<span class="text-gray-400 italic"> — полный</span>
|
||||
</p>
|
||||
<p class="text-gray-400">Строка-заголовок пропускается автоматически если во 2-й колонке не число.</p>
|
||||
<p class="font-medium text-gray-600">Колонки определяются автоматически по содержимому. Обязательны: PN и Кол-во. Лишние колонки (секция, код и т.п.) игнорируются.</p>
|
||||
<p>Цена поддерживает форматы: <span class="font-mono">$5114,00</span> · <span class="font-mono">5 114.00</span> · <span class="font-mono">5114</span></p>
|
||||
<p class="text-gray-400">Строка-заголовок пропускается автоматически.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bom-paste-area"
|
||||
@@ -327,23 +214,24 @@
|
||||
<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-left border-b">PN вендора</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-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>
|
||||
<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>
|
||||
<tr><td colspan="8" 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 colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
</tr>
|
||||
@@ -356,7 +244,10 @@
|
||||
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">
|
||||
= Сумма цен вендора
|
||||
Проставить цены BOM
|
||||
</button>
|
||||
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
</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>
|
||||
@@ -936,11 +827,19 @@ async function loadAllComponents() {
|
||||
const resp = await fetch('/api/components?per_page=5000');
|
||||
const data = await resp.json();
|
||||
allComponents = data.components || [];
|
||||
window._bomAllComponents = allComponents;
|
||||
} catch(e) {
|
||||
console.error('Failed to load components', e);
|
||||
allComponents = [];
|
||||
window._bomAllComponents = [];
|
||||
}
|
||||
}
|
||||
function _bomLots() {
|
||||
return [...new Set((window._bomAllComponents || allComponents).map(c => c.lot_name).filter(Boolean))].sort();
|
||||
}
|
||||
function _bomLotValid(v) {
|
||||
return (window._bomAllComponents || allComponents).some(c => c.lot_name === v);
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
const serverCountInput = document.getElementById('server-count');
|
||||
@@ -2658,36 +2557,97 @@ function switchTopTab(tab) {
|
||||
|
||||
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
|
||||
|
||||
// Parse a price string handling $, spaces, comma-as-decimal and comma-as-thousands.
|
||||
function parsePastePrice(s) {
|
||||
if (!s) return null;
|
||||
let v = s.replace(/[$\s]/g, '');
|
||||
// Determine decimal separator: if ends with ",dd" treat comma as decimal
|
||||
if (/,\d{1,2}$/.test(v)) {
|
||||
v = v.replace(/\./g, '').replace(',', '.');
|
||||
} else {
|
||||
v = v.replace(/,/g, '');
|
||||
}
|
||||
const n = parseFloat(v);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Auto-detect which column index serves as PN, qty, description, price.
|
||||
function detectBOMColumns(rows) {
|
||||
const ncols = Math.max(...rows.map(r => r.length));
|
||||
let qtyCol = -1, pnCol = -1, descCol = -1, priceCol = -1;
|
||||
|
||||
// Price: last column where ≥70% of values parse as non-null price
|
||||
for (let c = ncols - 1; c >= 0; c--) {
|
||||
const hits = rows.filter(r => parsePastePrice(r[c] || '') !== null).length;
|
||||
if (hits >= rows.length * 0.7) { priceCol = c; break; }
|
||||
}
|
||||
|
||||
// Qty: first column (before price) where all values are integers 1..9999
|
||||
for (let c = 0; c < ncols; c++) {
|
||||
if (c === priceCol) continue;
|
||||
const allInt = rows.every(r => /^\d{1,4}$/.test((r[c] || '').trim()));
|
||||
if (allInt) { qtyCol = c; break; }
|
||||
}
|
||||
|
||||
// PN: last column before qty that contains no spaces and looks like a product code
|
||||
if (qtyCol > 0) {
|
||||
for (let c = qtyCol - 1; c >= 0; c--) {
|
||||
const looksPN = rows.every(r => {
|
||||
const v = (r[c] || '').trim();
|
||||
return v.length > 0 && v.length <= 60 && !/\s{2,}/.test(v);
|
||||
});
|
||||
if (looksPN) { pnCol = c; break; }
|
||||
}
|
||||
}
|
||||
if (pnCol === -1) pnCol = 0;
|
||||
|
||||
// Description: column after qty with longest average text (excluding price col)
|
||||
let bestLen = -1;
|
||||
for (let c = (qtyCol !== -1 ? qtyCol + 1 : 1); c < ncols; c++) {
|
||||
if (c === priceCol) continue;
|
||||
const avg = rows.reduce((s, r) => s + (r[c] || '').length, 0) / rows.length;
|
||||
if (avg > bestLen) { bestLen = avg; descCol = c; }
|
||||
}
|
||||
|
||||
return { pnCol, qtyCol, descCol, priceCol };
|
||||
}
|
||||
|
||||
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());
|
||||
if (!lines.length) return;
|
||||
|
||||
// Split all rows
|
||||
let rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
||||
|
||||
// Skip header row: if qty column candidate on row 0 is not a number
|
||||
// We detect columns on all rows first (without header), then recheck row 0
|
||||
const { pnCol, qtyCol, descCol, priceCol } = detectBOMColumns(rows);
|
||||
|
||||
// Drop header if row[0][qtyCol] is not numeric
|
||||
if (qtyCol !== -1 && rows.length > 1 && !/^\d+$/.test((rows[0][qtyCol] || '').trim())) {
|
||||
rows = rows.slice(1);
|
||||
}
|
||||
|
||||
const parsed = [];
|
||||
for (const cols of rows) {
|
||||
const pn = pnCol !== -1 ? (cols[pnCol] || '').trim() : '';
|
||||
if (!pn) continue;
|
||||
|
||||
// Fixed positional format: PN | qty | [description] | [price]
|
||||
// Skip header: if col[1] is not numeric on the first row → skip that row
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const cols = lines[i].split('\t').map(c => c.trim());
|
||||
if (cols.length < 2) continue;
|
||||
|
||||
const pn = cols[0];
|
||||
const rawQty = cols[1].replace(/[, ]/g, '');
|
||||
|
||||
// Skip header row
|
||||
if (i === 0 && isNaN(parseInt(rawQty))) continue;
|
||||
|
||||
const rawQty = qtyCol !== -1 ? (cols[qtyCol] || '').trim() : '1';
|
||||
const qty = parseInt(rawQty) || 1;
|
||||
const description = cols.length >= 3 ? cols[2] : '';
|
||||
|
||||
const description = descCol !== -1 ? (cols[descCol] || '').trim() : '';
|
||||
|
||||
let unit_price = null, total_price = null;
|
||||
if (cols.length >= 4) {
|
||||
const rawPrice = cols[3].replace(/[, ]/g, '');
|
||||
unit_price = parseFloat(rawPrice) || null;
|
||||
if (unit_price) total_price = unit_price * qty;
|
||||
if (priceCol !== -1) {
|
||||
unit_price = parsePastePrice(cols[priceCol] || '');
|
||||
if (unit_price !== null) total_price = unit_price * qty;
|
||||
}
|
||||
|
||||
if (!pn) continue;
|
||||
parsed.push({
|
||||
sort_order: (parsed.length + 1) * 10,
|
||||
vendor_pn: pn,
|
||||
@@ -2739,6 +2699,18 @@ async function resolveBOM() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push unresolved PNs to server partnumber_seen registry (fire-and-forget)
|
||||
const unseen = bomRows
|
||||
.filter(r => !r.resolved_lot || r.resolution_source === 'unresolved')
|
||||
.map(r => ({ partnumber: r.vendor_pn, description: r.description || '' }));
|
||||
if (unseen.length) {
|
||||
fetch('/api/sync/partnumber-seen', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: unseen})
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Resolution failed:', e);
|
||||
}
|
||||
@@ -2747,6 +2719,11 @@ async function resolveBOM() {
|
||||
}
|
||||
|
||||
function renderBOMTable() {
|
||||
// Rebuild datalist for LOT autocomplete from current allComponents
|
||||
let dl = document.getElementById('lot-autocomplete-list');
|
||||
if (!dl) { dl = document.createElement('datalist'); dl.id = 'lot-autocomplete-list'; document.body.appendChild(dl); }
|
||||
dl.innerHTML = _bomLots().map(l => `<option value="${escapeHtml(l)}">`).join('');
|
||||
|
||||
const tbody = document.getElementById('bom-table-body');
|
||||
const cart = window._currentCart || [];
|
||||
|
||||
@@ -2776,9 +2753,10 @@ function renderBOMTable() {
|
||||
|
||||
let lotCell = '';
|
||||
if (isUnresolved) {
|
||||
lotCell = `<input type="text" placeholder="Введите LOT..." value="${row.manual_lot || ''}"
|
||||
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(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();"
|
||||
oninput="bomRows[${idx}].manual_lot = this.value;"
|
||||
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM();}else{this.value=bomRows[${idx}].manual_lot||'';}"
|
||||
list="lot-autocomplete-list">`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
@@ -2919,45 +2897,114 @@ async function loadVendorSpec(configUUID) {
|
||||
|
||||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||||
|
||||
function renderPricingTab() {
|
||||
async 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 compMap = {};
|
||||
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
|
||||
|
||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||||
let itemsForPriceLevels = [];
|
||||
if (bomRows.length) {
|
||||
const seen = new Set();
|
||||
bomRows.forEach(row => {
|
||||
const lot = row.resolved_lot;
|
||||
if (lot && row.resolution_source !== 'unresolved' && !seen.has(lot)) {
|
||||
seen.add(lot);
|
||||
itemsForPriceLevels.push({ lot_name: lot, quantity: row.quantity });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
itemsForPriceLevels = cart.map(item => ({ lot_name: item.lot_name, quantity: item.quantity }));
|
||||
}
|
||||
|
||||
const cart = window._currentCart || [];
|
||||
const cartMap = {};
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item; });
|
||||
// Fetch fresh price levels for these LOTs
|
||||
const priceMap = {}; // lot_name → {estimate_price, ...}
|
||||
if (itemsForPriceLevels.length) {
|
||||
try {
|
||||
const payload = {
|
||||
items: itemsForPriceLevels,
|
||||
pricelist_ids: Object.fromEntries(
|
||||
Object.entries(selectedPricelistIds)
|
||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||
)
|
||||
};
|
||||
const resp = await fetch('/api/quote/price-levels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
|
||||
}
|
||||
} catch(e) { /* silent — pricing tab renders with available data */ }
|
||||
}
|
||||
|
||||
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0;
|
||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false;
|
||||
let totalVendor = 0, totalEstimate = 0;
|
||||
let hasVendor = false, hasEstimate = 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; }
|
||||
if (!bomRows.length) {
|
||||
if (!cart.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||
tfoot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
cart.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row');
|
||||
const pl = priceMap[item.lot_name];
|
||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||||
const estimateTotal = estUnit * item.quantity;
|
||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||||
tr.dataset.est = estimateTotal;
|
||||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||||
tr.dataset.vendorOrig = '';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</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);
|
||||
});
|
||||
} else {
|
||||
bomRows.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row');
|
||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||
const pl = row.resolved_lot ? priceMap[row.resolved_lot] : null;
|
||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||||
const rowEst = estimateUnit != null ? estimateUnit * row.quantity : 0;
|
||||
|
||||
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);
|
||||
});
|
||||
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 (estimateUnit != null) { totalEstimate += rowEst; hasEstimate = true; }
|
||||
|
||||
tr.dataset.est = rowEst;
|
||||
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
|
||||
const desc = row.description || (row.resolved_lot ? ((compMap[row.resolved_lot] || {}).description || '') : '');
|
||||
tr.innerHTML = `
|
||||
<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 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateUnit != null ? formatCurrency(rowEst) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</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) : '—';
|
||||
@@ -2965,30 +3012,104 @@ function renderPricingTab() {
|
||||
document.getElementById('pricing-total-warehouse').textContent = '—';
|
||||
tfoot.classList.remove('hidden');
|
||||
|
||||
// Update custom price discount info
|
||||
// Update custom price proportional breakdown
|
||||
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;
|
||||
// Apply per-row BOM prices directly (not proportional redistribution)
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
rows.forEach((tr, i) => {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
const orig = tr.dataset.vendorOrig;
|
||||
if (orig !== '') {
|
||||
const v = parseFloat(orig);
|
||||
cell.textContent = formatCurrency(v);
|
||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||||
total += v;
|
||||
hasAny = true;
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.classList.add('text-gray-400');
|
||||
cell.classList.remove('text-blue-700');
|
||||
}
|
||||
});
|
||||
document.getElementById('pricing-custom-price').value = totalVendor.toFixed(2);
|
||||
onPricingCustomPriceInput();
|
||||
|
||||
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
||||
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
||||
|
||||
// Update discount info only
|
||||
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
let estimateTotal = 0;
|
||||
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||||
const discountEl = document.getElementById('pricing-discount-info');
|
||||
const pctEl = document.getElementById('pricing-discount-pct');
|
||||
if (hasAny && total > 0 && estimateTotal > 0) {
|
||||
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%';
|
||||
discountEl.classList.remove('hidden');
|
||||
} else {
|
||||
discountEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
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 rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
let estimateTotal = 0;
|
||||
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||||
|
||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
||||
|
||||
if (customPrice > 0 && estimateTotal > 0) {
|
||||
// Proportionally redistribute custom price → Цена вендора cells
|
||||
let assigned = 0;
|
||||
rows.forEach((tr, i) => {
|
||||
const est = parseFloat(tr.dataset.est) || 0;
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
let share;
|
||||
if (i === rows.length - 1) {
|
||||
share = customPrice - assigned;
|
||||
} else {
|
||||
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
|
||||
assigned += share;
|
||||
}
|
||||
cell.textContent = formatCurrency(share);
|
||||
cell.classList.add('text-blue-700');
|
||||
cell.classList.remove('text-gray-400');
|
||||
});
|
||||
totalVendorEl.textContent = formatCurrency(customPrice);
|
||||
} else {
|
||||
// Restore original vendor prices from BOM
|
||||
rows.forEach((tr, i) => {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
const orig = tr.dataset.vendorOrig;
|
||||
if (orig !== '') {
|
||||
cell.textContent = formatCurrency(parseFloat(orig));
|
||||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.classList.add('text-gray-400');
|
||||
cell.classList.remove('text-blue-700');
|
||||
}
|
||||
});
|
||||
// Recompute vendor total from originals
|
||||
let origTotal = 0; let hasOrig = false;
|
||||
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
||||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||||
}
|
||||
|
||||
// Discount info
|
||||
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 + '%';
|
||||
@@ -2998,6 +3119,39 @@ function onPricingCustomPriceInput() {
|
||||
}
|
||||
}
|
||||
|
||||
function exportPricingCSV() {
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||||
|
||||
const csvEscape = v => {
|
||||
if (v == null) return '';
|
||||
const s = String(v).replace(/"/g, '""');
|
||||
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
const headers = ['LOT', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Цена вендора', 'Склад', 'Конкуренты'];
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
|
||||
rows.forEach(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const rowData = Array.from(cells).map(td => td.textContent.trim());
|
||||
lines.push(rowData.map(csvEscape).join(','));
|
||||
});
|
||||
|
||||
// Totals row
|
||||
const estTotal = document.getElementById('pricing-total-estimate').textContent.trim();
|
||||
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
|
||||
lines.push(['', '', '', 'Итого', estTotal, vendorTotal, '', ''].map(csvEscape).join(','));
|
||||
|
||||
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `pricing_${configUUID || 'export'}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
@@ -3008,27 +3162,6 @@ function formatCurrency(val) {
|
||||
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}}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
||||
@@ -59,50 +54,96 @@
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<details class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<summary class="px-4 py-3 cursor-pointer text-sm font-medium text-gray-700 hover:bg-gray-50 select-none">
|
||||
История снимков
|
||||
</summary>
|
||||
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</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-2 text-left">Версия</th>
|
||||
<th class="px-4 py-2 text-left">Дата</th>
|
||||
<th class="px-4 py-2 text-right">Позиций</th>
|
||||
<th class="px-4 py-2 text-center">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="books-table-body"></tbody>
|
||||
</table>
|
||||
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
|
||||
Нет загруженных снимков.
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
Снимки сопоставлений (Partnumber Books)
|
||||
</button>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Collapsible body -->
|
||||
<div id="books-section-body" class="hidden border-t">
|
||||
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</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-2 text-left">Версия</th>
|
||||
<th class="px-4 py-2 text-left">Дата</th>
|
||||
<th class="px-4 py-2 text-right">Позиций</th>
|
||||
<th class="px-4 py-2 text-center">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="books-table-body"></tbody>
|
||||
</table>
|
||||
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
|
||||
Нет загруженных снимков.
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="books-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
|
||||
<span id="books-page-info"></span>
|
||||
<div class="flex gap-2">
|
||||
<button id="books-prev" onclick="changeBooksPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
|
||||
<button id="books-next" onclick="changeBooksPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
let allBooks = [];
|
||||
let booksPage = 1;
|
||||
const BOOKS_PER_PAGE = 10;
|
||||
|
||||
function toggleBooksSection() {
|
||||
const body = document.getElementById('books-section-body');
|
||||
const chevron = document.getElementById('books-chevron');
|
||||
const collapsed = body.classList.toggle('hidden');
|
||||
chevron.style.transform = collapsed ? '' : 'rotate(90deg)';
|
||||
}
|
||||
|
||||
async function loadBooks() {
|
||||
const resp = await fetch('/api/partnumber-books');
|
||||
const data = await resp.json();
|
||||
const books = data.books || [];
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/partnumber-books');
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
allBooks = data.books || [];
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
|
||||
if (!books.length) {
|
||||
if (!allBooks.length) {
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
document.getElementById('summary-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill history table
|
||||
booksPage = 1;
|
||||
renderBooksPage();
|
||||
|
||||
const active = allBooks.find(b => b.is_active) || allBooks[0];
|
||||
await loadActiveBookItems(active);
|
||||
}
|
||||
|
||||
function renderBooksPage() {
|
||||
const total = allBooks.length;
|
||||
const totalPages = Math.ceil(total / BOOKS_PER_PAGE);
|
||||
const start = (booksPage - 1) * BOOKS_PER_PAGE;
|
||||
const pageBooks = allBooks.slice(start, start + BOOKS_PER_PAGE);
|
||||
|
||||
const tbody = document.getElementById('books-table-body');
|
||||
tbody.innerHTML = '';
|
||||
books.forEach(b => {
|
||||
pageBooks.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50';
|
||||
tr.innerHTML = `
|
||||
@@ -119,17 +160,36 @@ async function loadBooks() {
|
||||
});
|
||||
document.getElementById('books-table').classList.remove('hidden');
|
||||
|
||||
// Load active book detail
|
||||
const active = books.find(b => b.is_active) || books[0];
|
||||
await loadActiveBookItems(active);
|
||||
// Pagination controls
|
||||
if (total > BOOKS_PER_PAGE) {
|
||||
document.getElementById('books-pagination').classList.remove('hidden');
|
||||
document.getElementById('books-page-info').textContent =
|
||||
`Снимки ${start + 1}–${Math.min(start + BOOKS_PER_PAGE, total)} из ${total}`;
|
||||
document.getElementById('books-prev').disabled = booksPage === 1;
|
||||
document.getElementById('books-next').disabled = booksPage === totalPages;
|
||||
} else {
|
||||
document.getElementById('books-pagination').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function changeBooksPage(delta) {
|
||||
const totalPages = Math.ceil(allBooks.length / BOOKS_PER_PAGE);
|
||||
booksPage = Math.max(1, Math.min(totalPages, booksPage + delta));
|
||||
renderBooksPage();
|
||||
}
|
||||
|
||||
async function loadActiveBookItems(book) {
|
||||
const resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
const data = await resp.json();
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
|
||||
allItems = data.items || [];
|
||||
|
||||
// Compute stats
|
||||
const lots = new Set(allItems.map(i => i.lot_name));
|
||||
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
||||
|
||||
@@ -170,17 +230,21 @@ function filterItems(query) {
|
||||
}
|
||||
|
||||
async function syncPartnumberBooks() {
|
||||
let resp, data;
|
||||
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');
|
||||
}
|
||||
resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
showToast('Ошибка синхронизации', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
|
||||
loadBooks();
|
||||
} else if (data.blocked) {
|
||||
showToast(`Синк заблокирован: ${data.reason_text}`, 'error');
|
||||
} else {
|
||||
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +253,4 @@ document.addEventListener('DOMContentLoaded', loadBooks);
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user