feat: Add SvelteKit web app with scan sessions and import queue

Replaces the Flask/Alpine web app with a SvelteKit 2 + Svelte 5 rewrite
under web/, built on adapter-node and Tailwind v4. Same shape as the
reference b4l budget app — no auth, stateless pass-through to InvenTree.

New "scan session" flow groups mass scans into a session with live
counters (scanned / succeeded / pending / failed). Unknown parts in
import mode are fed to a worker pool that spawns inventree-part-import
(IMPORT_CONCURRENCY, default 3, with 3-retry). Anything that can't be
resolved automatically — parse errors, missing qty, invalid location,
API errors, or imports that exhaust retries — drops into a Failures
panel with a per-item Fix dialog (edit fields / search existing part /
retry import). CSV export on the failure list.

Layout is two-column on lg+: scanner + activity on the left, pending
imports + failures on the right. Light-theme default. SSE on
/api/events streams session and import events to the client.

Barcode parser ported from the Python/JS versions and hardened for
Digi-Key MH10.8.2 barcodes both with and without GS separators (old
parser greedy-matched Q's digits and read "Q6" + "11ZPICK" as 611).
Import worker also now treats a subprocess failure followed by a
successful findPart as a success, so partial imports (part created but
a duplicate parameter trips the DB constraint) no longer land in the
Failures panel.

Deploy artifacts: systemd unit, nginx example (SSE-friendly), and a
step-by-step deploy/README. Requires inventree-part-import >= 1.9.2 on
the server for InvenTree 1.x API compatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:58:57 +07:00
parent ed9a3307ef
commit 379ed232df
50 changed files with 5517 additions and 1 deletions
+459
View File
@@ -0,0 +1,459 @@
import { randomUUID } from 'node:crypto';
import type {
FailedScan,
FailureReason,
ScanMode,
ScanSession,
ServerEvent
} from '$lib/types';
import { bus, subscribe } from './events';
import { importQueue } from './importQueue';
import { parseScan } from './barcode';
import {
createStockItem,
findPart,
findStockItem,
getPartLocationsSummary,
getStockLevel,
patchStockQuantity,
searchParts
} from './inventree';
export interface ScanInput {
rawBarcode: string;
// overrides (used by retry flow)
partCode?: string | null;
quantity?: number | null;
locationId?: number | null;
partId?: number | null;
}
export type ScanOutcome =
| { outcome: 'ok'; message: string; detail?: Record<string, unknown> }
| { outcome: 'failed'; failure: FailedScan }
| { outcome: 'pending_import'; partCode: string };
interface InternalSession extends ScanSession {
// track pending imports for retry continuation
}
const sessions = new Map<string, InternalSession>();
function emitUpdate(session: InternalSession): void {
bus.publish({ type: 'session_update', session: clone(session) });
}
function clone(session: InternalSession): ScanSession {
return JSON.parse(JSON.stringify(session));
}
export function createSession(input: {
mode: ScanMode;
locationId: number | null;
}): ScanSession {
const session: InternalSession = {
id: randomUUID(),
startedAt: new Date().toISOString(),
endedAt: null,
mode: input.mode,
locationId: input.locationId,
stats: { scanned: 0, succeeded: 0, failed: 0, pending: 0 },
failures: [],
pendingImports: []
};
sessions.set(session.id, session);
emitUpdate(session);
return clone(session);
}
export function endSession(id: string): ScanSession | null {
const s = sessions.get(id);
if (!s) return null;
s.endedAt = new Date().toISOString();
emitUpdate(s);
return clone(s);
}
export function getSession(id: string): ScanSession | null {
const s = sessions.get(id);
return s ? clone(s) : null;
}
export function listSessions(): ScanSession[] {
return [...sessions.values()].map(clone);
}
export function updateSessionMode(id: string, mode: ScanMode): ScanSession | null {
const s = sessions.get(id);
if (!s) return null;
s.mode = mode;
emitUpdate(s);
return clone(s);
}
export function updateSessionLocation(
id: string,
locationId: number | null
): ScanSession | null {
const s = sessions.get(id);
if (!s) return null;
s.locationId = locationId;
emitUpdate(s);
return clone(s);
}
function recordFailure(
session: InternalSession,
data: {
reason: FailureReason;
message: string;
rawBarcode: string;
parsedPartCode: string | null;
parsedQuantity: number | null;
suggestions?: FailedScan['suggestions'];
}
): FailedScan {
const failure: FailedScan = {
id: randomUUID(),
rawBarcode: data.rawBarcode,
parsedPartCode: data.parsedPartCode,
parsedQuantity: data.parsedQuantity,
reason: data.reason,
message: data.message,
timestamp: new Date().toISOString(),
attempts: 1,
suggestions: data.suggestions
};
session.failures.push(failure);
session.stats.failed++;
emitUpdate(session);
return failure;
}
function markSucceeded(session: InternalSession): void {
session.stats.succeeded++;
emitUpdate(session);
}
export function dismissFailure(sessionId: string, failureId: string): ScanSession | null {
const s = sessions.get(sessionId);
if (!s) return null;
const before = s.failures.length;
s.failures = s.failures.filter((f) => f.id !== failureId);
if (s.failures.length < before) {
s.stats.failed = Math.max(0, s.stats.failed - 1);
emitUpdate(s);
}
return clone(s);
}
async function executeMode(
mode: ScanMode,
partId: number,
locationId: number | null,
quantity: number | null
): Promise<{ message: string; detail?: Record<string, unknown> }> {
if (mode === 'locate') {
const summary = await getPartLocationsSummary(partId);
return {
message: `Found in ${summary.locations.length} location(s); total ${summary.total}`,
detail: summary as unknown as Record<string, unknown>
};
}
if (mode === 'get') {
if (locationId === null) throw new Error('invalid_location');
const qty = await getStockLevel(partId, locationId);
return { message: `Current stock: ${qty}`, detail: { quantity: qty } };
}
if (locationId === null) throw new Error('invalid_location');
if (quantity === null || Number.isNaN(quantity)) throw new Error('missing_quantity');
if (mode === 'import') {
const existing = await findStockItem(partId, locationId);
if (existing) {
const current = Number(existing.quantity);
const next = current + quantity;
await patchStockQuantity(existing.pk, next);
return {
message: `Added ${quantity} (${current}${next})`,
detail: { previous: current, next, stockItemId: existing.pk }
};
}
const created = await createStockItem(partId, locationId, quantity);
return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } };
}
// mode === 'update'
const existing = await findStockItem(partId, locationId);
if (existing) {
const current = Number(existing.quantity);
await patchStockQuantity(existing.pk, quantity);
return {
message: `Updated stock ${current}${quantity}`,
detail: { previous: current, next: quantity, stockItemId: existing.pk }
};
}
const created = await createStockItem(partId, locationId, quantity);
return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } };
}
async function suggestParts(
partCode: string
): Promise<FailedScan['suggestions']> {
try {
const hits = await searchParts(partCode, 5);
return hits;
} catch {
return [];
}
}
export async function processScan(
sessionId: string,
input: ScanInput
): Promise<ScanOutcome> {
const session = sessions.get(sessionId);
if (!session) throw new Error(`Unknown session ${sessionId}`);
if (session.endedAt) throw new Error('Session already ended');
session.stats.scanned++;
const parsed = parseScan(input.rawBarcode);
const partCode = input.partCode ?? parsed.partCode;
const quantity = input.quantity ?? parsed.quantity;
const locationId = input.locationId ?? session.locationId;
const mode = session.mode;
if (!partCode && input.partId == null) {
const failure = recordFailure(session, {
reason: 'parse_failed',
message: `Could not parse part code from barcode: ${truncate(input.rawBarcode)}`,
rawBarcode: input.rawBarcode,
parsedPartCode: null,
parsedQuantity: quantity
});
return { outcome: 'failed', failure };
}
let partId: number | null = input.partId ?? null;
if (partId == null && partCode) {
try {
partId = await findPart(partCode);
} catch (e) {
const failure = recordFailure(session, {
reason: 'api_error',
message: e instanceof Error ? e.message : String(e),
rawBarcode: input.rawBarcode,
parsedPartCode: partCode,
parsedQuantity: quantity
});
return { outcome: 'failed', failure };
}
}
if (partId == null) {
if (mode === 'import' && partCode) {
enqueueImportAndContinue(session, {
rawBarcode: input.rawBarcode,
partCode,
quantity,
locationId
});
return { outcome: 'pending_import', partCode };
}
const suggestions = partCode ? await suggestParts(partCode) : [];
const failure = recordFailure(session, {
reason: 'unknown_part',
message: `Part "${partCode}" not found`,
rawBarcode: input.rawBarcode,
parsedPartCode: partCode,
parsedQuantity: quantity,
suggestions
});
return { outcome: 'failed', failure };
}
try {
const result = await executeMode(mode, partId, locationId, quantity);
markSucceeded(session);
return { outcome: 'ok', message: result.message, detail: result.detail };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const reason: FailureReason =
msg === 'missing_quantity'
? 'missing_quantity'
: msg === 'invalid_location'
? 'invalid_location'
: 'api_error';
const failure = recordFailure(session, {
reason,
message: reason === 'api_error' ? msg : humanReason(reason),
rawBarcode: input.rawBarcode,
parsedPartCode: partCode,
parsedQuantity: quantity
});
return { outcome: 'failed', failure };
}
}
function syncPendingCount(session: InternalSession): void {
session.stats.pending = session.pendingImports.length;
}
function removePending(session: InternalSession, partCode: string): void {
session.pendingImports = session.pendingImports.filter((p) => p.partCode !== partCode);
syncPendingCount(session);
}
function enqueueImportAndContinue(
session: InternalSession,
ctx: {
rawBarcode: string;
partCode: string;
quantity: number | null;
locationId: number | null;
}
): void {
// Deduplicate: if the same part is already being imported within this session,
// the worker-pool queue will merge our attempt with the in-flight one. Just
// refresh the visible entry's metadata and return without double-counting.
const existing = session.pendingImports.find((p) => p.partCode === ctx.partCode);
if (existing) {
existing.quantity = ctx.quantity;
existing.locationId = ctx.locationId;
emitUpdate(session);
return;
}
session.pendingImports.push({
id: randomUUID(),
partCode: ctx.partCode,
queuedAt: new Date().toISOString(),
attempts: 1,
quantity: ctx.quantity,
locationId: ctx.locationId,
mode: session.mode,
lastError: null
});
syncPendingCount(session);
emitUpdate(session);
void (async () => {
const result = await importQueue.enqueue(ctx.partCode, { sessionId: session.id });
removePending(session, ctx.partCode);
if (result.success && result.partId !== null) {
try {
await executeMode(session.mode, result.partId, ctx.locationId, ctx.quantity);
markSucceeded(session);
return;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const reason: FailureReason =
msg === 'missing_quantity'
? 'missing_quantity'
: msg === 'invalid_location'
? 'invalid_location'
: 'api_error';
recordFailure(session, {
reason,
message: reason === 'api_error' ? msg : humanReason(reason),
rawBarcode: ctx.rawBarcode,
parsedPartCode: ctx.partCode,
parsedQuantity: ctx.quantity
});
return;
}
}
recordFailure(session, {
reason: 'import_failed',
message: result.error ?? 'Import failed after retries',
rawBarcode: ctx.rawBarcode,
parsedPartCode: ctx.partCode,
parsedQuantity: ctx.quantity
});
})();
}
// Reflect retry progress on the visible pending list as the worker pool
// surfaces `import_retry` events from the bus.
subscribe((event) => {
if (event.type !== 'import_retry') return;
if (!event.sessionId) return;
const session = sessions.get(event.sessionId);
if (!session) return;
const entry = session.pendingImports.find((p) => p.partCode === event.partCode);
if (!entry) return;
entry.attempts = event.attempts + 1;
entry.lastError = event.error;
emitUpdate(session);
});
export interface RetryFixes {
partCode?: string;
quantity?: number;
locationId?: number;
partId?: number; // user picked an existing part
retryImport?: boolean;
}
export async function retryFailure(
sessionId: string,
failureId: string,
fixes: RetryFixes
): Promise<ScanOutcome> {
const session = sessions.get(sessionId);
if (!session) throw new Error(`Unknown session ${sessionId}`);
const failure = session.failures.find((f) => f.id === failureId);
if (!failure) throw new Error(`Unknown failure ${failureId}`);
failure.attempts++;
// decrement failed stat; if the retry fails we'll re-record
session.stats.failed = Math.max(0, session.stats.failed - 1);
session.failures = session.failures.filter((f) => f.id !== failureId);
// don't emit yet — processScan will emit
session.stats.scanned--; // processScan will ++ again
const input: ScanInput = {
rawBarcode: failure.rawBarcode,
partCode: fixes.partCode ?? failure.parsedPartCode,
quantity: fixes.quantity ?? failure.parsedQuantity,
locationId: fixes.locationId ?? session.locationId
};
if (fixes.partId != null) input.partId = fixes.partId;
if (fixes.retryImport && input.partCode) {
session.stats.scanned++;
enqueueImportAndContinue(session, {
rawBarcode: failure.rawBarcode,
partCode: input.partCode,
quantity: input.quantity ?? null,
locationId: input.locationId ?? null
});
return { outcome: 'pending_import', partCode: input.partCode };
}
return processScan(sessionId, input);
}
function truncate(s: string, n = 60): string {
return s.length <= n ? s : s.slice(0, n) + '…';
}
function humanReason(r: FailureReason): string {
switch (r) {
case 'missing_quantity':
return 'No quantity found in scan';
case 'invalid_location':
return 'No location selected for this operation';
default:
return r;
}
}
export type { ServerEvent };