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:
@@ -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": []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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,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}`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user