From bbc4b1e763e3724d8cd71ae5b74423dc7d80350d Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 22 May 2026 14:25:35 +0700 Subject: [PATCH] fix: Robust barcode scan handling for ANSI MH10.8.2 and InvenTree 1.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capture scanner control keystrokes (Ctrl+]/^/\/_ → GS/RS/FS/US) in the scan input so ANSI MH10.8.2 field separators survive the HTML input filter, eliminating the Q-quantity-vs-next-DI ambiguity. - Fall back to a DI-aware lazy regex when separators are stripped (e.g. pasted scans), so Q digits stop at the next data identifier instead of greedily eating into 11Z/12Z/etc. - Make pending-part dicts JSON-serializable by isoformat-ing the timestamp; without this the worker's import_complete socket emit threw and the entry was never removed from the queue, causing every re-scan to 400 with "already queued" forever. - Make /api/part/import idempotent: a re-scan of an already-queued part updates qty/location and returns 200 with already_queued=true instead of 400. - Surface search/queue errors in the client log instead of silently swallowing them, and stop treating a 500 from /api/part/search as "not found" (which was causing re-queue loops). - Log full tracebacks for /api/part/search failures and split the get_part_info / get_part_parameters error paths so failures can be attributed. - Migrate get_part_parameters to the InvenTree 1.x endpoint /api/parameter/?model_type=part.part&model_id=. The old /api/part/parameter/?part= returns 404 on this instance, and even on the new endpoint the ?part= filter is silently ignored (would have returned every parameter in the database). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 6 ++- src/stocktool/stock_tool_gui_v2.py | 48 ++++++++++++----- src/stocktool/web/app.py | 59 ++++++++++++++++++--- src/stocktool/web/static/js/app.js | 73 +++++++++++++++++++++++--- src/stocktool/web/templates/index.html | 1 + 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6532693..eaf1995 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,11 @@ "Bash(npx svelte-kit *)", "Bash(npx svelte-check *)", "Bash(npm run *)", - "Bash(npx tsx *)" + "Bash(npx tsx *)", + "Bash(git commit *)", + "Bash(git push *)", + "WebFetch(domain:inventree-b4l.newedge.house)", + "Bash(C:/dev/inventree-stock-tool/.venv/Scripts/python.exe *)" ], "deny": [], "ask": [] diff --git a/src/stocktool/stock_tool_gui_v2.py b/src/stocktool/stock_tool_gui_v2.py index 31eedeb..4fd5537 100644 --- a/src/stocktool/stock_tool_gui_v2.py +++ b/src/stocktool/stock_tool_gui_v2.py @@ -290,20 +290,28 @@ def get_part_info(host: str, token: str, part_id: int) -> Dict[str, Any]: def get_part_parameters(host: str, token: str, part_id: int) -> List[Dict[str, Any]]: """ Get part parameters from InvenTree API. - + Args: host: InvenTree server URL token: API authentication token part_id: Part ID to get parameters for - + Returns: List of parameter dictionaries """ - url = f"{host}/api/part/parameter/" + # InvenTree 1.x consolidated parameters under /api/parameter/ keyed by + # generic (model_type, model_id). The old /api/part/parameter/?part= + # endpoint no longer exists, and just `?part=` on the new endpoint + # is silently ignored (returns every parameter in the database). + url = f"{host}/api/parameter/" headers = {'Authorization': f'Token {token}'} - resp = requests.get(url, headers=headers, params={'part': part_id}) + resp = requests.get( + url, + headers=headers, + params={'model_type': 'part.part', 'model_id': part_id}, + ) resp.raise_for_status() - + data = resp.json() return data.get('results', data) if isinstance(data, dict) and 'results' in data else data @@ -461,12 +469,26 @@ def parse_scan(raw: str) -> Tuple[Optional[str], Optional[int]]: part = None qty = None - # Extract part number - it's after [)>06P and before the next field marker - # Look for common field markers: 1P, 30P (these are explicit markers) + # If GS/RS separators survived, parse field-by-field — unambiguous. + fields = SEP_RE.split(raw) + if len(fields) > 1: + for f in fields: + if not f: + continue + if f.startswith('30P'): + part = clean_part_code(f[3:]) + elif f.startswith('1P'): + if not part: + part = clean_part_code(f[2:]) + elif f.startswith('Q') and f[1:].isdigit(): + qty = int(f[1:]) + if part or qty: + return part, qty + + # Separators stripped — extract part after [)>06P up to next field marker. if len(raw) > 6 and raw[5] == 'P': after_p = raw[6:] - # Find the next explicit field marker markers_to_find = ['1P', '30P'] end_idx = len(after_p) @@ -477,11 +499,11 @@ def parse_scan(raw: str) -> Tuple[Optional[str], Optional[int]]: part = clean_part_code(after_p[:end_idx]) - # Extract quantity after Q - if 'Q' in raw: - q_matches = re.findall(r'Q(\d+)', raw) - if q_matches: - qty = int(q_matches[0]) + # Lazy-match Q digits, stopping at the next data identifier + # (e.g. 11Z, 12Z, 20Z, 1T, 9D, 4L) or end-of-string. + q_match = re.search(r'Q(\d+?)(?=\d{0,2}[A-Z]|$)', raw) + if q_match: + qty = int(q_match.group(1)) if part or qty: return part, qty diff --git a/src/stocktool/web/app.py b/src/stocktool/web/app.py index f2f7468..450e73a 100644 --- a/src/stocktool/web/app.py +++ b/src/stocktool/web/app.py @@ -7,6 +7,7 @@ A Flask-based web interface for barcode scanning and inventory management. import os import sys +import traceback from pathlib import Path from flask import Flask, render_template, request, jsonify from flask_socketio import SocketIO, emit @@ -61,6 +62,15 @@ def load_config(): } +def _pending_to_dict(p: PendingPart) -> dict: + """JSON-safe serialization of a PendingPart (datetime → isoformat).""" + d = asdict(p) + ts = d.get('timestamp') + if isinstance(ts, datetime): + d['timestamp'] = ts.isoformat() + return d + + def on_import_complete(result: ImportResult): """Handle import completion callback.""" pending_part = pending_parts.get(result.part_code) @@ -73,7 +83,7 @@ def on_import_complete(result: ImportResult): 'part_code': result.part_code, 'part_id': result.part_id, 'success': True, - 'pending_part': asdict(pending_part) + 'pending_part': _pending_to_dict(pending_part) }) # Remove from pending @@ -141,9 +151,29 @@ def search_part(): part_id = find_part(config['host'], config['token'], part_code) if part_id: - # Get part details - part_info = get_part_info(config['host'], config['token'], part_id) - parameters = get_part_parameters(config['host'], config['token'], part_id) + # Get part details — wrap each call so we can pinpoint which one + # raised. A 500 here used to be misread by the client as "not + # found", which then re-queued the part in an infinite loop. + try: + part_info = get_part_info(config['host'], config['token'], part_id) + except Exception as e: + print(f"[search_part] get_part_info({part_id}) failed: {e}") + traceback.print_exc() + return jsonify({ + 'success': False, + 'stage': 'get_part_info', + 'part_id': part_id, + 'error': str(e), + }), 500 + + try: + parameters = get_part_parameters(config['host'], config['token'], part_id) + except Exception as e: + print(f"[search_part] get_part_parameters({part_id}) failed: {e}") + traceback.print_exc() + # Don't fail the whole search just because parameters errored — + # the part exists; surface what we have. + parameters = [] return jsonify({ 'success': True, @@ -159,6 +189,8 @@ def search_part(): }) except Exception as e: + print(f"[search_part] unexpected failure for {part_code!r}: {e}") + traceback.print_exc() return jsonify({'success': False, 'error': str(e)}), 500 @@ -174,9 +206,20 @@ def queue_part_import(): if not part_code: return jsonify({'success': False, 'error': 'part_code required'}), 400 - # Check if already queued + # Idempotent: if already queued, acknowledge with the existing entry so + # re-scans don't surface as 400s. Update qty/location to the latest scan + # values — user likely re-scanned to correct something. if part_code in pending_parts: - return jsonify({'success': False, 'error': 'Part already queued for import'}), 400 + existing = pending_parts[part_code] + if quantity is not None: + existing.quantity = quantity + if location_id: + existing.location_id = location_id + return jsonify({ + 'success': True, + 'already_queued': True, + 'pending_part': _pending_to_dict(existing) + }) # Create pending part pending_part = PendingPart( @@ -193,7 +236,7 @@ def queue_part_import(): return jsonify({ 'success': True, - 'pending_part': asdict(pending_part) + 'pending_part': _pending_to_dict(pending_part) }) @@ -395,7 +438,7 @@ def get_pending(): """Get all pending part imports.""" return jsonify({ 'success': True, - 'pending': [asdict(p) for p in pending_parts.values()] + 'pending': [_pending_to_dict(p) for p in pending_parts.values()] }) diff --git a/src/stocktool/web/static/js/app.js b/src/stocktool/web/static/js/app.js index 6617c7b..26f2ae9 100644 --- a/src/stocktool/web/static/js/app.js +++ b/src/stocktool/web/static/js/app.js @@ -165,6 +165,27 @@ function stockApp() { return loc ? `${loc.id} - ${loc.name}` : `Location ${this.currentLocation}`; }, + /** + * Capture scanner control keystrokes (GS/RS/FS) that + * would otherwise drop. HID scanners send field separators as Ctrl+key + * combos — without this the ANSI MH10.8.2 separators are lost and the + * parser has to guess where Q's digits end. + */ + onScanKeydown(e) { + if (!e.ctrlKey || e.altKey || e.metaKey) return; + + let ctrlChar = null; + if (e.key === ']') ctrlChar = '\x1D'; // GS — Ctrl+] + else if (e.key === '^') ctrlChar = '\x1E'; // RS — Ctrl+Shift+6 + else if (e.key === '\\') ctrlChar = '\x1C'; // FS — Ctrl+\ + else if (e.key === '_') ctrlChar = '\x1F'; // US — Ctrl+Shift+- + + if (ctrlChar) { + e.preventDefault(); + this.scanInput += ctrlChar; + } + }, + /** * Process scanned part */ @@ -249,11 +270,29 @@ function stockApp() { // 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 GS/RS separators survived, parse field-by-field — unambiguous. + const sepRegex = /[\x1D\x1E]/g; + const fields = raw.split(sepRegex); + + if (fields.length > 1) { + for (const field of fields) { + if (!field) continue; + if (field.startsWith('30P')) { + part_code = cleanPartCode(field.substring(3)); + } else if (field.startsWith('1P')) { + if (!part_code) part_code = cleanPartCode(field.substring(2)); + } else if (/^Q\d+$/.test(field)) { + quantity = parseInt(field.substring(1)); + } + } + return { part_code, quantity }; + } + + // Separators stripped (HTML drops control chars). + // Extract part number after [)>06P, up to next explicit 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; @@ -267,8 +306,9 @@ function stockApp() { part_code = cleanPartCode(afterP.substring(0, endIdx)); } - // Extract quantity after Q - const qMatch = raw.match(/Q(\d+)/); + // Lazy-match Q digits, stopping at the next data identifier + // (e.g. 11Z, 12Z, 20Z, 1T, 9D, 4L) or end-of-string. + const qMatch = raw.match(/Q(\d+?)(?=\d{0,2}[A-Z]|$)/); if (qMatch) { quantity = parseInt(qMatch[1]); } @@ -341,6 +381,14 @@ function stockApp() { const data = await response.json(); + // Server error → do NOT treat as "not found" or we'll re-queue + // a part that was already imported and loop forever. + if (!response.ok || data.success === false) { + const stage = data.stage ? ` [${data.stage}]` : ''; + this.log('error', `✖ Search failed${stage}: ${data.error || response.statusText}`); + return; + } + if (data.success && data.found) { // Part found this.currentPart = data.part_info; @@ -528,8 +576,21 @@ function stockApp() { const data = await response.json(); if (data.success) { - this.pendingParts.push(data.pending_part); - this.log('info', `📋 Added ${partCode} to import queue - continue scanning`); + if (data.already_queued) { + // Update local copy so qty/location reflect latest scan + const idx = this.pendingParts.findIndex(p => p.part_code === partCode); + if (idx >= 0) { + this.pendingParts[idx] = data.pending_part; + } else { + this.pendingParts.push(data.pending_part); + } + this.log('info', `↻ ${partCode} already in import queue (updated qty)`); + } else { + this.pendingParts.push(data.pending_part); + this.log('info', `📋 Added ${partCode} to import queue - continue scanning`); + } + } else { + this.log('error', `✖ Queue rejected: ${data.error || 'unknown error'}`); } } catch (error) { this.log('error', `✖ Error queuing import: ${error.message}`); diff --git a/src/stocktool/web/templates/index.html b/src/stocktool/web/templates/index.html index 8e10f5a..014b3f0 100644 --- a/src/stocktool/web/templates/index.html +++ b/src/stocktool/web/templates/index.html @@ -103,6 +103,7 @@