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-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": []
+32 -10
View File
@@ -299,9 +299,17 @@ def get_part_parameters(host: str, token: str, part_id: int) -> List[Dict[str, A
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=<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}'}
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()
@@ -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
+49 -6
View File
@@ -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
# 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()]
})
+65 -4
View File
@@ -165,6 +165,27 @@ function stockApp() {
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
*/
@@ -249,11 +270,29 @@ function stockApp() {
// Handle ANSI MH10.8.2 format: [)>06P<part>...
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') {
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,9 +576,22 @@ function stockApp() {
const data = await response.json();
if (data.success) {
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}`);
}
+1
View File
@@ -103,6 +103,7 @@
<input
type="text"
x-model="scanInput"
@keydown="onScanKeydown($event)"
@keyup.enter="processScan"
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"