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 } | { outcome: 'failed'; failure: FailedScan } | { outcome: 'pending_import'; partCode: string }; interface InternalSession extends ScanSession { // track pending imports for retry continuation } const sessions = new Map(); 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 }> { 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 }; } 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 { try { const hits = await searchParts(partCode, 5); return hits; } catch { return []; } } export async function processScan( sessionId: string, input: ScanInput ): Promise { 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 { 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 };