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:
@@ -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 };
|
||||
Reference in New Issue
Block a user