379ed232df
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>
460 lines
12 KiB
TypeScript
460 lines
12 KiB
TypeScript
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 };
|