fix: Robust barcode scan handling for ANSI MH10.8.2 and InvenTree 1.x

- 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=<id>. The old
  /api/part/parameter/?part=<id> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:25:35 +07:00
parent 379ed232df
commit bbc4b1e763
5 changed files with 159 additions and 28 deletions
+5 -1
View File
@@ -14,7 +14,11 @@
"Bash(npx svelte-kit *)", "Bash(npx svelte-kit *)",
"Bash(npx svelte-check *)", "Bash(npx svelte-check *)",
"Bash(npm run *)", "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": [], "deny": [],
"ask": [] "ask": []
+32 -10
View File
@@ -299,9 +299,17 @@ def get_part_parameters(host: str, token: str, part_id: int) -> List[Dict[str, A
Returns: Returns:
List of parameter dictionaries 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=<id>
# endpoint no longer exists, and just `?part=<id>` on the new endpoint
# is silently ignored (returns every parameter in the database).
url = f"{host}/api/parameter/"
headers = {'Authorization': f'Token {token}'} 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() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -461,12 +469,26 @@ def parse_scan(raw: str) -> Tuple[Optional[str], Optional[int]]:
part = None part = None
qty = None qty = None
# Extract part number - it's after [)>06P and before the next field marker # If GS/RS separators survived, parse field-by-field — unambiguous.
# Look for common field markers: 1P, 30P (these are explicit markers) 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': if len(raw) > 6 and raw[5] == 'P':
after_p = raw[6:] after_p = raw[6:]
# Find the next explicit field marker
markers_to_find = ['1P', '30P'] markers_to_find = ['1P', '30P']
end_idx = len(after_p) 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]) part = clean_part_code(after_p[:end_idx])
# Extract quantity after Q # Lazy-match Q digits, stopping at the next data identifier
if 'Q' in raw: # (e.g. 11Z, 12Z, 20Z, 1T, 9D, 4L) or end-of-string.
q_matches = re.findall(r'Q(\d+)', raw) q_match = re.search(r'Q(\d+?)(?=\d{0,2}[A-Z]|$)', raw)
if q_matches: if q_match:
qty = int(q_matches[0]) qty = int(q_match.group(1))
if part or qty: if part or qty:
return part, qty return part, qty
+51 -8
View File
@@ -7,6 +7,7 @@ A Flask-based web interface for barcode scanning and inventory management.
import os import os
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
from flask import Flask, render_template, request, jsonify from flask import Flask, render_template, request, jsonify
from flask_socketio import SocketIO, emit 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): def on_import_complete(result: ImportResult):
"""Handle import completion callback.""" """Handle import completion callback."""
pending_part = pending_parts.get(result.part_code) pending_part = pending_parts.get(result.part_code)
@@ -73,7 +83,7 @@ def on_import_complete(result: ImportResult):
'part_code': result.part_code, 'part_code': result.part_code,
'part_id': result.part_id, 'part_id': result.part_id,
'success': True, 'success': True,
'pending_part': asdict(pending_part) 'pending_part': _pending_to_dict(pending_part)
}) })
# Remove from pending # Remove from pending
@@ -141,9 +151,29 @@ def search_part():
part_id = find_part(config['host'], config['token'], part_code) part_id = find_part(config['host'], config['token'], part_code)
if part_id: if part_id:
# Get part details # Get part details — wrap each call so we can pinpoint which one
part_info = get_part_info(config['host'], config['token'], part_id) # raised. A 500 here used to be misread by the client as "not
parameters = get_part_parameters(config['host'], config['token'], part_id) # 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({ return jsonify({
'success': True, 'success': True,
@@ -159,6 +189,8 @@ def search_part():
}) })
except Exception as e: 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 return jsonify({'success': False, 'error': str(e)}), 500
@@ -174,9 +206,20 @@ def queue_part_import():
if not part_code: if not part_code:
return jsonify({'success': False, 'error': 'part_code required'}), 400 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: 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 # Create pending part
pending_part = PendingPart( pending_part = PendingPart(
@@ -193,7 +236,7 @@ def queue_part_import():
return jsonify({ return jsonify({
'success': True, '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.""" """Get all pending part imports."""
return jsonify({ return jsonify({
'success': True, 'success': True,
'pending': [asdict(p) for p in pending_parts.values()] 'pending': [_pending_to_dict(p) for p in pending_parts.values()]
}) })
+67 -6
View File
@@ -165,6 +165,27 @@ function stockApp() {
return loc ? `${loc.id} - ${loc.name}` : `Location ${this.currentLocation}`; return loc ? `${loc.id} - ${loc.name}` : `Location ${this.currentLocation}`;
}, },
/**
* Capture scanner control keystrokes (GS/RS/FS) that <input type="text">
* 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 * Process scanned part
*/ */
@@ -249,11 +270,29 @@ function stockApp() {
// Handle ANSI MH10.8.2 format: [)>06P<part>... // Handle ANSI MH10.8.2 format: [)>06P<part>...
if (raw.startsWith('[)>06')) { 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 <input> drops control chars).
// Extract part number after [)>06P, up to next explicit field marker.
if (raw.length > 6 && raw[5] === 'P') { if (raw.length > 6 && raw[5] === 'P') {
const afterP = raw.substring(6); const afterP = raw.substring(6);
// Find the next explicit field marker (1P or 30P)
const markers = ['1P', '30P']; const markers = ['1P', '30P'];
let endIdx = afterP.length; let endIdx = afterP.length;
@@ -267,8 +306,9 @@ function stockApp() {
part_code = cleanPartCode(afterP.substring(0, endIdx)); part_code = cleanPartCode(afterP.substring(0, endIdx));
} }
// Extract quantity after Q // Lazy-match Q digits, stopping at the next data identifier
const qMatch = raw.match(/Q(\d+)/); // (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) { if (qMatch) {
quantity = parseInt(qMatch[1]); quantity = parseInt(qMatch[1]);
} }
@@ -341,6 +381,14 @@ function stockApp() {
const data = await response.json(); 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) { if (data.success && data.found) {
// Part found // Part found
this.currentPart = data.part_info; this.currentPart = data.part_info;
@@ -528,8 +576,21 @@ function stockApp() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.pendingParts.push(data.pending_part); if (data.already_queued) {
this.log('info', `📋 Added ${partCode} to import queue - continue scanning`); // 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) { } catch (error) {
this.log('error', `✖ Error queuing import: ${error.message}`); this.log('error', `✖ Error queuing import: ${error.message}`);
+1
View File
@@ -103,6 +103,7 @@
<input <input
type="text" type="text"
x-model="scanInput" x-model="scanInput"
@keydown="onScanKeydown($event)"
@keyup.enter="processScan" @keyup.enter="processScan"
placeholder="Scan part barcode or enter part code..." placeholder="Scan part barcode or enter part code..."
class="flex-1 px-4 py-3 text-lg bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" class="flex-1 px-4 py-3 text-lg bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"