/** * InvenTree Stock Tool - Web Application * Alpine.js + Socket.IO client application */ function stockApp() { return { // Connection socket: null, connected: false, // Configuration config: {}, modes: {}, locations: [], // Current state currentLocation: '', currentMode: 'import', locationInput: '', scanInput: '', // Part data currentPart: null, currentParameters: null, currentStock: null, // Pending imports pendingParts: [], // Activity log logs: [], maxLogs: 100, /** * Initialize application */ async init() { this.log('info', '🚀 Initializing InvenTree Stock Tool...'); // Load configuration await this.loadConfig(); // Connect WebSocket this.connectWebSocket(); // Load locations await this.loadLocations(); // Focus on scan input this.$nextTick(() => { this.$refs.scanInput?.focus(); }); this.log('success', '✅ Application ready'); }, /** * Load configuration from server */ async loadConfig() { try { const response = await fetch('/api/config'); const data = await response.json(); this.config = data; this.modes = data.modes || {}; this.log('success', `📡 Connected to ${data.host}`); } catch (error) { this.log('error', `✖ Failed to load config: ${error.message}`); } }, /** * Connect to WebSocket server */ connectWebSocket() { this.socket = io(); this.socket.on('connect', () => { this.connected = true; this.log('success', '🔌 WebSocket connected'); }); this.socket.on('disconnect', () => { this.connected = false; this.log('warning', '🔌 WebSocket disconnected'); }); this.socket.on('barcode_parsed', (data) => { this.log('info', `📋 Parsed: ${data.part_code} (Qty: ${data.quantity || 'N/A'})`); }); this.socket.on('import_complete', (data) => { this.log('success', `✅ Import complete: ${data.part_code}`); this.removePendingPart(data.part_code); // Process the imported part this.processImportedPart(data); }); this.socket.on('import_retry', (data) => { this.log('warning', `⚠ Import retry ${data.retry_count}: ${data.part_code}`); this.updatePendingPart(data.part_code, data.retry_count); }); this.socket.on('import_failed', (data) => { this.log('error', `✖ Import failed: ${data.part_code} - ${data.error}`); this.removePendingPart(data.part_code); }); }, /** * Load available locations */ async loadLocations() { try { const response = await fetch('/api/locations'); const data = await response.json(); if (data.success) { this.locations = data.locations; this.log('success', `📍 Loaded ${data.locations.length} locations`); } } catch (error) { this.log('error', `✖ Failed to load locations: ${error.message}`); } }, /** * Process location scan/input */ processLocationScan() { const input = this.locationInput.trim(); if (!input) return; // Check if it's a barcode (INV-SL prefix) if (input.toUpperCase().startsWith('INV-SL')) { const locId = parseInt(input.substring(6)); if (!isNaN(locId)) { this.currentLocation = locId; this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`); } } else if (!isNaN(input)) { // Direct ID this.currentLocation = parseInt(input); this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`); } this.locationInput = ''; }, /** * Handle location dropdown change */ onLocationChange() { if (this.currentLocation) { this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`); this.$refs.scanInput?.focus(); } }, /** * Get current location name */ getCurrentLocationName() { const loc = this.locations.find(l => l.id == this.currentLocation); return loc ? `${loc.id} - ${loc.name}` : `Location ${this.currentLocation}`; }, /** * Process scanned part */ async processScan() { const input = this.scanInput.trim(); if (!input) return; this.log('info', `🔍 Processing: ${input}`); // Parse barcode const { part_code, quantity } = await this.parseBarcode(input); if (!part_code) { this.log('warning', '⚠ Could not parse part code'); this.scanInput = ''; return; } // Check if it's a command if (this.handleBarcodeCommand(part_code)) { this.scanInput = ''; return; } // Validate location for non-locate modes if (this.currentMode !== 'locate' && !this.currentLocation) { this.log('warning', '⚠ Please select a location first'); this.scanInput = ''; return; } // Search for part await this.searchAndProcessPart(part_code, quantity); this.scanInput = ''; this.$refs.scanInput?.focus(); }, /** * Parse barcode input * * Supports multiple formats: * - JSON-like: {PM:PART-CODE,QTY:10} * - Separator-based: Fields separated by GS/RS (\x1D, \x1E) * - ANSI MH10.8.2: [)>06P...Q... */ async parseBarcode(raw) { let part_code = null; let quantity = null; /** * Clean part code by removing non-ASCII characters */ const cleanPartCode = (code) => { // Keep only ASCII printable characters (32-126) return code.split('').filter(char => { const charCode = char.charCodeAt(0); return charCode >= 32 && charCode <= 126; }).join('').trim(); }; // Handle JSON-like format: {PM:PART-CODE,QTY:10} if (raw.startsWith('{') && raw.includes('}')) { const content = raw.trim().slice(1, -1); for (const kv of content.split(',')) { if (!kv.includes(':')) continue; const [k, v] = kv.split(':', 2); const key = k.trim().toUpperCase(); const val = v.trim(); if (key === 'PM') { part_code = cleanPartCode(val); } else if (key === 'QTY') { const parsed = parseInt(val); if (!isNaN(parsed)) quantity = parsed; } } return { part_code, quantity }; } // Handle ANSI MH10.8.2 format: [)>06P... if (raw.startsWith('[)>06')) { // Extract part number - it's after [)>06P and before the next field marker if (raw.length > 6 && raw[5] === 'P') { const afterP = raw.substring(6); // Find the next explicit field marker (1P or 30P) const markers = ['1P', '30P']; let endIdx = afterP.length; for (const marker of markers) { const idx = afterP.indexOf(marker); if (idx !== -1 && idx < endIdx) { endIdx = idx; } } part_code = cleanPartCode(afterP.substring(0, endIdx)); } // Extract quantity after Q const qMatch = raw.match(/Q(\d+)/); if (qMatch) { quantity = parseInt(qMatch[1]); } return { part_code, quantity }; } // Handle separator-based format (GS/RS separators: \x1D or \x1E) const sepRegex = /[\x1D\x1E]/g; const fields = raw.split(sepRegex); for (const field of fields) { if (!field) continue; // Part code patterns: 30P, 1P if (field.startsWith('30P')) { part_code = cleanPartCode(field.substring(3)); } else if (field.toLowerCase().startsWith('1p')) { part_code = cleanPartCode(field.substring(2)); } // Quantity pattern: Q followed by digits else if (field.toLowerCase().startsWith('q') && /^\d+$/.test(field.substring(1))) { quantity = parseInt(field.substring(1)); } } return { part_code, quantity }; }, /** * Handle barcode commands */ handleBarcodeCommand(code) { const upper = code.toUpperCase(); // Mode switching const modeMap = { 'MODE:ADD': 'import', 'MODE:IMPORT': 'import', 'ADD_STOCK': 'import', 'IMPORT': 'import', 'MODE:UPDATE': 'update', 'UPDATE_STOCK': 'update', 'UPDATE': 'update', 'MODE:CHECK': 'get', 'MODE:GET': 'get', 'CHECK_STOCK': 'get', 'CHECK': 'get', 'MODE:LOCATE': 'locate', 'LOCATE_PART': 'locate', 'LOCATE': 'locate', 'FIND_PART': 'locate' }; if (modeMap[upper]) { this.currentMode = modeMap[upper]; this.log('info', `🔄 Switched to ${this.modes[this.currentMode]} mode`); return true; } // Location change if (['CHANGE_LOCATION', 'NEW_LOCATION', 'SET_LOCATION', 'LOCATION'].includes(upper)) { this.currentLocation = ''; this.log('info', '📍 Location change mode - scan new location'); return true; } return false; }, /** * Search and process part */ async searchAndProcessPart(partCode, quantity) { try { const response = await fetch('/api/part/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_code: partCode }) }); const data = await response.json(); if (data.success && data.found) { // Part found this.currentPart = data.part_info; this.currentParameters = data.parameters; // Fix image URLs - convert relative paths to full InvenTree URLs if (this.currentPart.image && this.currentPart.image.startsWith('/')) { this.currentPart.image = this.config.host + this.currentPart.image; } if (this.currentPart.thumbnail && this.currentPart.thumbnail.startsWith('/')) { this.currentPart.thumbnail = this.config.host + this.currentPart.thumbnail; } this.log('success', `✅ Found part: ${partCode}`); // Execute operation await this.executeOperation(data.part_id, partCode, quantity); } else { // Part not found - queue for import this.log('warning', `⏳ Part ${partCode} not found - queuing for import`); await this.queueImport(partCode, quantity); } } catch (error) { this.log('error', `✖ Error: ${error.message}`); } }, /** * Execute stock operation based on mode */ async executeOperation(partId, partCode, quantity) { switch (this.currentMode) { case 'import': await this.addStock(partId, quantity); break; case 'update': await this.updateStock(partId); break; case 'get': await this.checkStock(partId); break; case 'locate': await this.locatePart(partId); break; } }, /** * Add stock */ async addStock(partId, quantity) { if (!quantity) { this.log('warning', '⚠ No quantity found in scan'); return; } try { const response = await fetch('/api/stock/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_id: partId, location_id: this.currentLocation, quantity: quantity }) }); const data = await response.json(); if (data.success) { if (data.action === 'updated') { this.log('success', `✔ Added ${quantity}× (${data.previous_quantity} → ${data.new_quantity})`); } else { this.log('success', `✔ Created new stock item with ${quantity}×`); } this.currentStock = data.new_quantity || data.quantity; } } catch (error) { this.log('error', `✖ Error adding stock: ${error.message}`); } }, /** * Update stock */ async updateStock(partId) { const newQty = prompt('Enter new stock quantity:', this.currentStock || 0); if (newQty === null) return; const quantity = parseInt(newQty); if (isNaN(quantity)) { this.log('error', '✖ Invalid quantity'); return; } try { const response = await fetch('/api/stock/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_id: partId, location_id: this.currentLocation, quantity: quantity }) }); const data = await response.json(); if (data.success) { this.log('success', `✔ Updated stock: ${data.previous_quantity || 0} → ${quantity}`); this.currentStock = quantity; } } catch (error) { this.log('error', `✖ Error updating stock: ${error.message}`); } }, /** * Check stock */ async checkStock(partId) { try { const response = await fetch('/api/stock/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_id: partId, location_id: this.currentLocation }) }); const data = await response.json(); if (data.success) { this.currentStock = data.quantity; this.log('info', `ℹ Current stock: ${data.quantity}`); } } catch (error) { this.log('error', `✖ Error checking stock: ${error.message}`); } }, /** * Locate part */ async locatePart(partId) { try { const response = await fetch('/api/part/locate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_id: partId }) }); const data = await response.json(); if (data.success) { this.log('info', `📍 Found in ${data.locations.length} location(s)`); data.locations.forEach(loc => { this.log('info', ` 📌 ${loc.location_path || loc.location_name} - Qty: ${loc.quantity}`); }); this.log('info', `📊 Total stock: ${data.total_stock}`); this.currentStock = data.total_stock; } } catch (error) { this.log('error', `✖ Error locating part: ${error.message}`); } }, /** * Queue part for import */ async queueImport(partCode, quantity) { try { const response = await fetch('/api/part/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_code: partCode, quantity: quantity, location_id: this.currentLocation, mode: this.currentMode }) }); const data = await response.json(); if (data.success) { this.pendingParts.push(data.pending_part); this.log('info', `📋 Added ${partCode} to import queue - continue scanning`); } } catch (error) { this.log('error', `✖ Error queuing import: ${error.message}`); } }, /** * Process imported part */ async processImportedPart(data) { if (data.part_id) { await this.searchAndProcessPart(data.part_code, data.pending_part.quantity); } }, /** * Update pending part retry count */ updatePendingPart(partCode, retryCount) { const part = this.pendingParts.find(p => p.part_code === partCode); if (part) { part.retry_count = retryCount; } }, /** * Remove pending part */ removePendingPart(partCode) { this.pendingParts = this.pendingParts.filter(p => p.part_code !== partCode); }, /** * Add log message */ log(type, message) { const time = new Date().toLocaleTimeString(); this.logs.push({ type, message, time }); // Keep only last N logs if (this.logs.length > this.maxLogs) { this.logs.shift(); } // Auto-scroll log this.$nextTick(() => { const container = this.$refs.logContainer; if (container) { container.scrollTop = container.scrollHeight; } }); }, /** * Get log CSS class */ getLogClass(type) { const classes = { 'info': 'text-blue-400', 'success': 'text-green-400', 'warning': 'text-yellow-400', 'error': 'text-red-400' }; return classes[type] || 'text-gray-400'; } }; }