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
+17
View File
@@ -0,0 +1,17 @@
# InvenTree server (no trailing slash)
INVENTREE_HOST=https://your-inventree-server.example.com
INVENTREE_TOKEN=your-api-token-here
# Port adapter-node listens on (bound to 127.0.0.1 via HOST)
PORT=3000
HOST=127.0.0.1
ORIGIN=https://stock-tool.your-domain.example.com
# Max concurrent `inventree-part-import` processes
IMPORT_CONCURRENCY=3
# Absolute path to inventree-part-import binary. Leave empty to resolve from PATH.
INVENTREE_PART_IMPORT_BIN=
# Per-import timeout, seconds
IMPORT_TIMEOUT_SEC=60
+8
View File
@@ -0,0 +1,8 @@
node_modules
.svelte-kit
build
.env
.env.*
!.env.example
.DS_Store
*.log
+73
View File
@@ -0,0 +1,73 @@
# InvenTree Stock Tool — Web (SvelteKit)
SvelteKit rewrite of the Flask/Alpine web app.
## Stack
- SvelteKit 2 + Svelte 5 (runes) + adapter-node
- Tailwind CSS v4
- In-process scan-session store (no database)
- Server-Sent Events for live session/import updates
- Shell-out to `inventree-part-import` for unknown-part creation
## Dev
```bash
cd web
npm install
cp .env.example .env
# edit .env to point at your InvenTree server
npm run dev
```
Open http://localhost:5173.
## Build
```bash
npm run build
node build
```
## Feature: Scan Session
- A session groups a run of scans. Every scan goes through `/api/session/scan`
and is recorded in the session's stats.
- Unknown parts in "Add Stock" mode are enqueued for `inventree-part-import`;
workers run in parallel (tunable via `IMPORT_CONCURRENCY`, default 3).
- Failures (unknown parts outside import mode, missing qty, invalid location,
API errors, and imports that exhaust 3 retries) land in the Failures panel.
- Each failure has a **Fix** dialog — override the part code / quantity /
location, or search for an existing part and link it instead. Retry feeds
back through the same code path.
- Export CSV of failures for offline follow-up.
## Layout
```
web/
├── src/
│ ├── app.css, app.html, app.d.ts
│ ├── lib/
│ │ ├── barcode.ts shared parse_scan + commands
│ │ ├── client.ts fetch wrappers for API routes
│ │ ├── types.ts shared types
│ │ ├── server/ ← imported only from +server.ts / hooks
│ │ │ ├── env.ts typed env config
│ │ │ ├── inventree.ts 8 API helpers
│ │ │ ├── events.ts SSE pubsub
│ │ │ ├── importQueue.ts N-worker pool → inventree-part-import
│ │ │ └── sessions.ts in-memory scan sessions
│ │ ├── stores/app.svelte.ts
│ │ └── components/…
│ └── routes/
│ ├── +layout.svelte, +page.svelte
│ └── api/
│ ├── config, locations, proxy/image, part/search, events
│ └── session/{start,end,mode,location,scan,retry,failures}
└── deploy/ systemd unit, nginx example, deploy README
```
## Deployment
See [`deploy/README.md`](deploy/README.md).
+86
View File
@@ -0,0 +1,86 @@
# Deployment
Deploy target: Linux server, Node 20+, nginx fronting `adapter-node` on 127.0.0.1:3000, systemd managing the process. Python 3.10+ with `inventree-part-import` installed alongside Node — we shell out to it for unknown parts.
## One-time setup
```bash
sudo adduser --system --group --home /opt/inventree-stock-tool stock-tool
sudo chown -R stock-tool:stock-tool /opt/inventree-stock-tool
# As the service user:
sudo -u stock-tool -H bash
cd /opt/inventree-stock-tool
# Python side
python3 -m venv .venv
source .venv/bin/activate
pip install "inventree-part-import>=1.9.2" # 1.9.2+ required for InvenTree 1.x API
# configure inventree-part-import's suppliers at
# ~/.config/inventree-part-import/ (Digi-Key, Mouser, LCSC API keys)
# Node side — pushed via git or rsync into /opt/inventree-stock-tool
npm ci --omit=dev
npm run build
# Env
cp .env.example .env
$EDITOR .env
```
`.env` must contain at minimum `INVENTREE_HOST`, `INVENTREE_TOKEN`, and
`INVENTREE_PART_IMPORT_BIN=/opt/inventree-stock-tool/.venv/bin/inventree-part-import`.
## systemd
```bash
sudo cp deploy/inventree-stock-tool.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now inventree-stock-tool
sudo systemctl status inventree-stock-tool
journalctl -u inventree-stock-tool -f
```
Adjust `User=`, `Group=`, `WorkingDirectory=` in the unit file to match your
setup. The unit pins `INVENTREE_PART_IMPORT_BIN` via `.env` — the `PATH=` line
in the unit is a fallback for when the binary is on a regular `bin` directory.
## nginx
```bash
sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/stock-tool
sudo ln -s /etc/nginx/sites-available/stock-tool /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
Then issue a cert:
```bash
sudo certbot --nginx -d stock-tool.your-domain.example.com
```
## Updating
```bash
# on build server / laptop:
rsync -az --delete --exclude=node_modules --exclude=build \
./web/ stock-tool@host:/opt/inventree-stock-tool/
# on server:
sudo -u stock-tool -H bash
cd /opt/inventree-stock-tool
npm ci --omit=dev
npm run build
exit
sudo systemctl restart inventree-stock-tool
```
## Tuning
- `IMPORT_CONCURRENCY` (default 3) — number of concurrent `inventree-part-import`
subprocesses. Bump up only if supplier APIs aren't rate-limiting you and
InvenTree handles concurrent category/manufacturer writes cleanly. 34 is
typically the sweet spot; higher values tend to produce 429s rather than
speedups.
- `IMPORT_TIMEOUT_SEC` (default 60) — per-attempt timeout. Raise if your
supplier API is slow or the server cold-starts imports.
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=InvenTree Stock Tool (SvelteKit)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/inventree-stock-tool
EnvironmentFile=/opt/inventree-stock-tool/.env
# Prepend user-site bin so `inventree-part-import` installed via `pip install --user`
# is on PATH. Adjust the path if you install it into a venv instead.
Environment=PATH=/opt/inventree-stock-tool/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/node build
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
# Basic hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/opt/inventree-stock-tool
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
server {
listen 443 ssl http2;
server_name stock-tool.your-domain.example.com;
ssl_certificate /etc/letsencrypt/live/stock-tool.your-domain.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stock-tool.your-domain.example.com/privkey.pem;
# SvelteKit / adapter-node backend
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streaming (SSE) needs these off/long
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
# WebSocket-style upgrades (not used currently, kept for future)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 80;
server_name stock-tool.your-domain.example.com;
return 301 https://$host$request_uri;
}
+2481
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "inventree-stock-tool-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+94
View File
@@ -0,0 +1,94 @@
import { parseScan } from '../src/lib/barcode.ts';
const GS = '\x1D';
const RS = '\x1E';
// User's Digi-Key barcode as the scanner actually delivers it (with GS between
// fields and the RS format-envelope after `[)>`).
const digiKey = [
'[)>',
RS,
'06',
GS,
'P2073-USB1035-GF-P-0-B-B-ND',
GS,
'1PUSB1035-GF-P-0-B-B',
GS,
'30P2073-USB1035-GF-P-0-B-B-ND',
GS,
'K',
GS,
'1K9867254010',
GS,
'10K1242607169',
GS,
'D25511',
GS,
'T251215-501',
GS,
'11K1',
GS,
'4LCN',
GS,
'Q6',
GS,
'11ZPICK',
GS,
'12Z1064967213',
GS,
'13Z9999992',
GS,
'20Z' + '0'.repeat(55)
].join('');
const cases = [
{
name: "Digi-Key MH10.8.2 with GS separators (qty=6)",
raw: digiKey,
expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 }
},
{
name: "same barcode but scanner stripped all separators (fallback)",
raw: '[)>06P2073-USB1035-GF-P-0-B-B-ND1PUSB1035-GF-P-0-B-B30P2073-USB1035-GF-P-0-B-B-NDK1K9867254010K1242607169D25511T251215-50111K14LCNQ611ZPICK12Z1064967213Z99999920Z0000000000000000000000000000000000000000000000000000000',
expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 }
},
{
name: 'GS-separated without MH10.8.2 envelope',
raw: `30PABC${GS}Q15`,
expected: { partCode: 'ABC', quantity: 15 }
},
{
name: 'Quantity legitimately 611 before 11Z (fallback path)',
raw: '[)>06PABC1PXYZQ61111ZPICK',
expected: { partCode: 'ABC', quantity: 611 }
},
{
name: 'MH10.8.2 with separators, only 1P present',
raw: `[)>06${GS}1PMYPART${GS}Q42`,
expected: { partCode: 'MYPART', quantity: 42 }
},
{
name: 'JSON-like',
raw: '{PM:WIDGET-42,QTY:7}',
expected: { partCode: 'WIDGET-42', quantity: 7 }
}
];
let pass = 0;
let fail = 0;
for (const c of cases) {
const got = parseScan(c.raw);
const ok =
got.partCode === c.expected.partCode && got.quantity === c.expected.quantity;
if (ok) {
pass++;
console.log(`ok ${c.name}`);
} else {
fail++;
console.log(`FAIL ${c.name}`);
console.log(` got ${JSON.stringify(got)}`);
console.log(` expected ${JSON.stringify(c.expected)}`);
}
}
console.log(`\n${pass}/${pass + fail} pass`);
process.exit(fail ? 1 : 0);
+11
View File
@@ -0,0 +1,11 @@
@import 'tailwindcss';
@theme {
--color-brand-500: oklch(0.55 0.15 240);
--color-brand-600: oklch(0.48 0.16 240);
}
html,
body {
height: 100%;
}
+12
View File
@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>InvenTree Stock Tool</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-slate-50 text-slate-900">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
import type { ParsedBarcode } from './types';
const SEP_RE = /[\x1D\x1E]/g;
const SEP_RE_TEST = /[\x1D\x1E]/;
// Matches the MH10.8.2 envelope `[)>06` with an optional RS after `[)>` and
// any trailing GS/RS separators.
const MH10_ENVELOPE = /^\[\)>\x1E?06[\x1D\x1E]*/;
// DIs that commonly follow `Q`. Used only when the scanner stripped the GS/RS
// separators and we have to re-tokenize a continuous string.
const DIS_AFTER_Q = [
'11Z',
'12Z',
'13Z',
'14Z',
'20Z',
'21Z',
'1Z',
'1T',
'2T',
'3T',
'1L',
'4L',
'14L'
];
function cleanPartCode(code: string): string {
let out = '';
for (const ch of code) {
const c = ch.charCodeAt(0);
if (c >= 32 && c <= 126) out += ch;
}
return out.trim();
}
function parseJsonBraces(raw: string): ParsedBarcode {
const content = raw.trim().slice(1, -1);
let partCode: string | null = null;
let quantity: number | null = null;
for (const kv of content.split(',')) {
if (!kv.includes(':')) continue;
const [k, ...rest] = kv.split(':');
const v = rest.join(':');
const key = k.trim().toUpperCase();
const val = v.trim();
if (key === 'PM') partCode = cleanPartCode(val);
else if (key === 'QTY') {
const n = parseInt(val, 10);
if (!Number.isNaN(n)) quantity = n;
}
}
return { partCode, quantity };
}
// Parse fields separated by GS (0x1D) or RS (0x1E). Each field starts with a
// Data Identifier (DI) followed by its value. We care about part codes (30P
// beats 1P beats bare P) and quantity (Q).
function parseSeparatedFields(text: string): ParsedBarcode {
let pkgPart: string | null = null; // 30P — Digi-Key / container part
let mfrPart: string | null = null; // 1P — manufacturer part
let custPart: string | null = null; // P — customer part
let quantity: number | null = null;
for (const field of text.split(SEP_RE)) {
if (!field) continue;
if (field.startsWith('30P')) pkgPart = cleanPartCode(field.substring(3));
else if (field.startsWith('1P')) mfrPart = cleanPartCode(field.substring(2));
else if (field[0] === 'P') custPart = cleanPartCode(field.substring(1));
else if (field[0] === 'Q' && /^\d+$/.test(field.substring(1)))
quantity = parseInt(field.substring(1), 10);
}
return { partCode: pkgPart ?? mfrPart ?? custPart, quantity };
}
// Fallback for MH10.8.2 barcodes whose GS/RS separators have been stripped.
// We can still pull out the part code by locating the next known DI, and the
// quantity by reading digits after `Q` until we hit a following DI.
function parseRunonMh10(body: string): ParsedBarcode {
let partCode: string | null = null;
let quantity: number | null = null;
if (body.length > 0 && body[0] === 'P') {
const afterP = body.substring(1);
let endIdx = afterP.length;
for (const marker of ['1P', '30P']) {
const idx = afterP.indexOf(marker);
if (idx !== -1 && idx < endIdx) endIdx = idx;
}
partCode = cleanPartCode(afterP.substring(0, endIdx));
}
let from = 0;
while (from < body.length) {
const q = body.indexOf('Q', from);
if (q === -1) break;
let i = q + 1;
let digits = '';
while (i < body.length) {
if (DIS_AFTER_Q.some((di) => body.startsWith(di, i))) break;
const ch = body.charCodeAt(i);
if (ch < 48 || ch > 57) break;
digits += body[i];
i++;
}
if (digits) {
quantity = parseInt(digits, 10);
break;
}
from = q + 1;
}
return { partCode, quantity };
}
export function parseScan(raw: string): ParsedBarcode {
if (!raw) return { partCode: null, quantity: null };
if (raw.startsWith('{') && raw.includes('}')) return parseJsonBraces(raw);
if (MH10_ENVELOPE.test(raw)) {
const body = raw.replace(MH10_ENVELOPE, '');
if (SEP_RE_TEST.test(body)) return parseSeparatedFields(body);
return parseRunonMh10(body);
}
// Plain (possibly GS/RS-separated) — e.g. a bare `30PABC\x1DQ5`.
return parseSeparatedFields(raw);
}
const COMMAND_TO_MODE: Record<string, 'import' | 'update' | 'get' | 'locate'> = {
'MODE:ADD': 'import',
'MODE:IMPORT': 'import',
ADD_STOCK: 'import',
IMPORT: 'import',
'MODE:UPDATE': 'update',
UPDATE_STOCK: 'update',
UPDATE: 'update',
'MODE:CHECK': 'get',
'MODE:GET': 'get',
CHECK_STOCK: 'get',
CHECK: 'get',
'MODE:LOCATE': 'locate',
LOCATE_PART: 'locate',
LOCATE: 'locate',
FIND_PART: 'locate'
};
const LOCATION_COMMANDS = new Set([
'CHANGE_LOCATION',
'NEW_LOCATION',
'SET_LOCATION',
'LOCATION'
]);
export type BarcodeCommand =
| { type: 'mode'; mode: 'import' | 'update' | 'get' | 'locate' }
| { type: 'change_location' }
| null;
export function interpretCommand(code: string): BarcodeCommand {
const upper = code.toUpperCase();
const mode = COMMAND_TO_MODE[upper];
if (mode) return { type: 'mode', mode };
if (LOCATION_COMMANDS.has(upper)) return { type: 'change_location' };
return null;
}
export function parseLocationBarcode(input: string): number | null {
const trimmed = input.trim();
if (trimmed.toUpperCase().startsWith('INV-SL')) {
const n = parseInt(trimmed.substring(6), 10);
return Number.isNaN(n) ? null : n;
}
const n = parseInt(trimmed, 10);
return Number.isNaN(n) ? null : n;
}
+136
View File
@@ -0,0 +1,136 @@
import type { FailedScan, ScanMode, ScanSession } from './types';
export interface RetryFixes {
partCode?: string;
quantity?: number;
locationId?: number;
partId?: number;
retryImport?: boolean;
}
export type ScanOutcome =
| { outcome: 'ok'; message: string; detail?: Record<string, unknown> }
| { outcome: 'failed'; failure: FailedScan }
| { outcome: 'pending_import'; partCode: string };
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status}: ${text || res.statusText}`);
}
return (await res.json()) as T;
}
export const api = {
async config() {
return handle<{
host: string;
modes: Record<string, string>;
barcodeCommands: Record<string, string[]>;
importConcurrency: number;
}>(await fetch('/api/config'));
},
async locations() {
return handle<{ locations: Array<{ id: number; name: string; pathstring?: string }> }>(
await fetch('/api/locations')
);
},
async startSession(mode: ScanMode, locationId: number | null) {
return handle<{ session: ScanSession }>(
await fetch('/api/session/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, locationId })
})
);
},
async endSession(sessionId: string) {
return handle<{ session: ScanSession }>(
await fetch('/api/session/end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId })
})
);
},
async setSessionMode(sessionId: string, mode: ScanMode) {
return handle<{ session: ScanSession }>(
await fetch('/api/session/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, mode })
})
);
},
async setSessionLocation(sessionId: string, locationId: number | null) {
return handle<{ session: ScanSession }>(
await fetch('/api/session/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, locationId })
})
);
},
async scan(
sessionId: string,
rawBarcode: string,
overrides: Partial<{
partCode: string;
quantity: number;
locationId: number;
partId: number;
}> = {}
) {
return handle<{ outcome: ScanOutcome; session: ScanSession }>(
await fetch('/api/session/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, rawBarcode, ...overrides })
})
);
},
async retry(sessionId: string, failureId: string, fixes: RetryFixes) {
return handle<{ outcome: ScanOutcome; session: ScanSession }>(
await fetch('/api/session/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, failureId, fixes })
})
);
},
async dismissFailure(sessionId: string, failureId: string) {
return handle<{ session: ScanSession }>(
await fetch('/api/session/failures', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, failureId })
})
);
}
};
export function failuresToCsv(failures: FailedScan[]): string {
const rows = [
['id', 'timestamp', 'reason', 'partCode', 'quantity', 'rawBarcode', 'message'],
...failures.map((f) => [
f.id,
f.timestamp,
f.reason,
f.parsedPartCode ?? '',
f.parsedQuantity ?? '',
f.rawBarcode,
f.message
])
];
return rows
.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))
.join('\r\n');
}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
let container: HTMLDivElement | undefined = $state();
$effect(() => {
// re-run when log length changes
app.logs.length;
queueMicrotask(() => {
if (container) container.scrollTop = container.scrollHeight;
});
});
function toneClass(t: string) {
switch (t) {
case 'success':
return 'text-emerald-700';
case 'warning':
return 'text-amber-700';
case 'error':
return 'text-rose-700';
default:
return 'text-sky-700';
}
}
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-lg font-semibold text-slate-800">Activity</h2>
<div
bind:this={container}
class="h-64 overflow-y-auto rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-sm"
>
{#each app.logs as log (log.id)}
<div class="py-0.5 {toneClass(log.type)}">
<span class="mr-2 text-slate-400">{log.time}</span>
<span>{log.message}</span>
</div>
{:else}
<div class="text-slate-400">No activity yet.</div>
{/each}
</div>
</section>
@@ -0,0 +1,24 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
</script>
<div class="flex items-center gap-3 text-sm">
<span
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium"
class:bg-emerald-100={app.connected}
class:text-emerald-700={app.connected}
class:bg-rose-100={!app.connected}
class:text-rose-700={!app.connected}
>
<span
class="h-2 w-2 rounded-full"
class:bg-emerald-500={app.connected}
class:bg-rose-500={!app.connected}
class:animate-pulse={app.connected}
></span>
{app.connected ? 'Connected' : 'Disconnected'}
</span>
{#if app.host}
<span class="text-slate-500">{app.host}</span>
{/if}
</div>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { FailedScan } from '$lib/types';
import type { RetryFixes } from '$lib/client';
import { app } from '$lib/stores/app.svelte';
let {
failure,
onClose,
onSubmit
}: {
failure: FailedScan;
onClose: () => void;
onSubmit: (fixes: RetryFixes) => void | Promise<void>;
} = $props();
// Dialog is mounted under `{#key failure.id}` so these initial values
// always reflect the active failure.
// svelte-ignore state_referenced_locally
let partCode = $state(failure.parsedPartCode ?? '');
// svelte-ignore state_referenced_locally
let quantity = $state<number | ''>(failure.parsedQuantity ?? '');
let locationId = $state<number | ''>(app.session?.locationId ?? '');
// svelte-ignore state_referenced_locally
let searchQuery = $state(failure.parsedPartCode ?? '');
let searching = $state(false);
// svelte-ignore state_referenced_locally
let results = $state(failure.suggestions ?? []);
let selectedPartId = $state<number | null>(null);
async function runSearch() {
const q = searchQuery.trim();
if (!q) return;
searching = true;
try {
const res = await fetch('/api/part/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, limit: 10 })
});
if (res.ok) {
const data = await res.json();
results = data.results ?? [];
}
} finally {
searching = false;
}
}
async function submitFixes(fixes: RetryFixes) {
await onSubmit(fixes);
}
function handlePickSuggestion(id: number) {
selectedPartId = id;
}
function handleApply() {
const fixes: RetryFixes = {};
if (partCode && partCode !== failure.parsedPartCode) fixes.partCode = partCode;
if (quantity !== '' && quantity !== failure.parsedQuantity) fixes.quantity = Number(quantity);
if (locationId !== '' && locationId !== app.session?.locationId)
fixes.locationId = Number(locationId);
if (selectedPartId != null) fixes.partId = selectedPartId;
submitFixes(fixes);
}
function handleRetryImport() {
submitFixes({ retryImport: true, partCode: partCode || undefined });
}
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
<div
class="w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-6 shadow-2xl"
>
<div class="mb-4 flex items-start justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-800">Fix failure</h3>
<p class="text-sm text-slate-600">{failure.message}</p>
</div>
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-sm text-slate-700 hover:bg-slate-50"
>Close</button
>
</div>
<div class="mb-4 rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
<div class="grid grid-cols-2 gap-2 text-slate-600">
<div>
<span class="text-slate-400">Raw:</span>
<span class="ml-2 font-mono text-slate-800">{failure.rawBarcode}</span>
</div>
<div>
<span class="text-slate-400">Reason:</span>
<span class="ml-2 text-slate-800">{failure.reason}</span>
</div>
</div>
</div>
<!-- Overrides form -->
<div class="mb-4 grid gap-3 md:grid-cols-3">
<label class="block text-sm">
<span class="text-slate-600">Part code</span>
<input
type="text"
bind:value={partCode}
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
/>
</label>
<label class="block text-sm">
<span class="text-slate-600">Quantity</span>
<input
type="number"
bind:value={quantity}
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
/>
</label>
<label class="block text-sm">
<span class="text-slate-600">Location</span>
<select
bind:value={locationId}
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
>
<option value="">(use session)</option>
{#each app.locations as loc (loc.id)}
<option value={loc.id}>{loc.id} {loc.name}</option>
{/each}
</select>
</label>
</div>
<!-- Search existing part -->
<div class="mb-4 rounded-md border border-slate-200 p-3">
<div class="mb-2 flex items-center gap-2">
<input
type="text"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && runSearch()}
placeholder="Search existing parts…"
class="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
/>
<button
type="button"
onclick={runSearch}
disabled={searching}
class="rounded-md bg-brand-500 px-3 py-2 text-sm text-white shadow-sm hover:bg-brand-600 disabled:opacity-50"
>{searching ? 'Searching…' : 'Search'}</button
>
</div>
{#if results.length > 0}
<ul class="max-h-48 space-y-1 overflow-y-auto">
{#each results as r (r.pk)}
<li>
<button
type="button"
onclick={() => handlePickSuggestion(r.pk)}
class="w-full rounded-md border px-3 py-2 text-left text-sm transition"
class:border-brand-500={selectedPartId === r.pk}
class:bg-brand-500={selectedPartId === r.pk}
class:text-white={selectedPartId === r.pk}
class:border-slate-200={selectedPartId !== r.pk}
class:bg-white={selectedPartId !== r.pk}
class:hover:bg-slate-50={selectedPartId !== r.pk}
>
<span class="font-mono">{r.IPN ?? '—'}</span>
<span class="ml-2 opacity-70">{r.name}</span>
</button>
</li>
{/each}
</ul>
{:else if searching}
<p class="text-sm text-slate-500">Searching…</p>
{:else}
<p class="text-sm text-slate-500">No suggestions yet. Enter a query above.</p>
{/if}
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
{#if failure.reason === 'import_failed' || failure.reason === 'unknown_part'}
<button
type="button"
onclick={handleRetryImport}
class="rounded-md border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-800 hover:bg-amber-100"
>Retry import</button
>
{/if}
<button
type="button"
onclick={handleApply}
class="rounded-md bg-emerald-600 px-4 py-2 text-sm text-white shadow-sm hover:bg-emerald-700"
>Apply &amp; retry</button
>
</div>
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
import type { FailedScan } from '$lib/types';
let {
onFix,
onDismiss,
onExport
}: {
onFix: (failure: FailedScan) => void;
onDismiss: (failureId: string) => void;
onExport: () => void;
} = $props();
const failures = $derived(app.session?.failures ?? []);
const reasonLabel: Record<string, string> = {
unknown_part: 'Unknown part',
import_failed: 'Import failed',
parse_failed: 'Parse failed',
missing_quantity: 'Missing quantity',
invalid_location: 'No location',
api_error: 'API error'
};
const reasonTone: Record<string, string> = {
unknown_part: 'bg-amber-50 text-amber-700 border-amber-200',
import_failed: 'bg-rose-50 text-rose-700 border-rose-200',
parse_failed: 'bg-orange-50 text-orange-700 border-orange-200',
missing_quantity: 'bg-sky-50 text-sky-700 border-sky-200',
invalid_location: 'bg-sky-50 text-sky-700 border-sky-200',
api_error: 'bg-rose-50 text-rose-700 border-rose-200'
};
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-800">
Failures
{#if failures.length > 0}
<span class="ml-2 rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700"
>{failures.length}</span
>
{/if}
</h2>
{#if failures.length > 0}
<button
type="button"
onclick={onExport}
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-xs text-slate-700 hover:bg-slate-50"
>Export CSV</button
>
{/if}
</div>
{#if failures.length === 0}
<p class="text-sm text-slate-500">No failures.</p>
{:else}
<ul class="space-y-2">
{#each failures as f (f.id)}
<li class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-900">
{f.parsedPartCode ?? '—'}
</span>
<span
class="inline-block rounded-full border px-2 py-0.5 text-xs {reasonTone[
f.reason
] ?? ''}">{reasonLabel[f.reason] ?? f.reason}</span
>
{#if f.parsedQuantity != null}
<span class="text-xs text-slate-500">qty {f.parsedQuantity}</span>
{/if}
</div>
<div class="mt-1 text-sm text-slate-700">{f.message}</div>
<div class="mt-1 text-xs text-slate-400">
{new Date(f.timestamp).toLocaleTimeString()}
</div>
</div>
<div class="flex gap-1">
<button
type="button"
onclick={() => onFix(f)}
class="rounded-md bg-brand-500 px-3 py-1 text-xs text-white shadow-sm hover:bg-brand-600"
>Fix</button
>
<button
type="button"
onclick={() => onDismiss(f.id)}
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-xs text-slate-600 hover:bg-slate-50"
title="Dismiss"
>
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
@@ -0,0 +1,66 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
let {
onChange
}: {
onChange: (locationId: number | null) => void;
} = $props();
let scanInput = $state('');
const currentName = $derived.by(() => {
const id = app.session?.locationId;
if (id == null) return null;
const loc = app.locations.find((l) => l.id === id);
return loc ? `${loc.id}${loc.name}` : `Location ${id}`;
});
function handleScan() {
const trimmed = scanInput.trim();
if (!trimmed) return;
let id: number | null = null;
if (trimmed.toUpperCase().startsWith('INV-SL')) {
const n = parseInt(trimmed.substring(6), 10);
id = Number.isNaN(n) ? null : n;
} else {
const n = parseInt(trimmed, 10);
id = Number.isNaN(n) ? null : n;
}
if (id != null) onChange(id);
scanInput = '';
}
function handleSelect(e: Event) {
const value = (e.target as HTMLSelectElement).value;
onChange(value ? Number(value) : null);
}
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-lg font-semibold text-slate-800">Location</h2>
<div class="grid gap-3 md:grid-cols-2">
<input
type="text"
bind:value={scanInput}
onkeydown={(e) => e.key === 'Enter' && handleScan()}
placeholder="Scan location (INV-SL…) or enter ID"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
/>
<select
value={app.session?.locationId ?? ''}
onchange={handleSelect}
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
>
<option value="">Select a location…</option>
{#each app.locations as loc (loc.id)}
<option value={loc.id}>{loc.id} {loc.name}</option>
{/each}
</select>
</div>
{#if currentName}
<p class="mt-3 text-sm text-emerald-700">Current: {currentName}</p>
{:else}
<p class="mt-3 text-sm text-slate-500">No location selected</p>
{/if}
</section>
@@ -0,0 +1,32 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
import type { ScanMode } from '$lib/types';
let { onChange }: { onChange: (mode: ScanMode) => void } = $props();
const modes: ScanMode[] = ['import', 'update', 'get', 'locate'];
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-lg font-semibold text-slate-800">Mode</h2>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4">
{#each modes as mode (mode)}
{@const active = app.session?.mode === mode}
<button
type="button"
onclick={() => onChange(mode)}
class="rounded-md border px-3 py-2 text-sm font-medium transition"
class:border-brand-500={active}
class:bg-brand-500={active}
class:text-white={active}
class:shadow-sm={active}
class:border-slate-300={!active}
class:bg-white={!active}
class:text-slate-700={!active}
class:hover:bg-slate-50={!active}
>
{app.modes[mode] ?? mode}
</button>
{/each}
</div>
</section>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
const items = $derived(app.session?.pendingImports ?? []);
function locationName(id: number | null): string {
if (id == null) return '—';
const loc = app.locations.find((l) => l.id === id);
return loc ? `${loc.id}${loc.name}` : `#${id}`;
}
function elapsed(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 1000) return 'just now';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s ago`;
}
// Tick every second so "elapsed" stays fresh.
let now = $state(Date.now());
$effect(() => {
const id = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(id);
});
$effect(() => {
// Reference `now` so the template re-computes elapsed strings.
void now;
});
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-800">
Pending imports
{#if items.length > 0}
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700"
>{items.length}</span
>
{/if}
</h2>
<span class="text-xs text-slate-500">
up to {app.importConcurrency} in parallel
</span>
</div>
{#if items.length === 0}
<p class="text-sm text-slate-500">Nothing in the queue.</p>
{:else}
<ul class="space-y-2">
{#each items as item (item.id)}
<li class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="relative flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-70"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-amber-500"></span>
</span>
<span class="truncate font-mono text-sm font-medium text-slate-900"
>{item.partCode}</span
>
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500">
<span>Qty: <span class="text-slate-700">{item.quantity ?? '—'}</span></span>
<span>Loc: <span class="text-slate-700">{locationName(item.locationId)}</span></span>
<span
>Attempt: <span class="text-slate-700">{item.attempts}/3</span></span
>
<span title={item.queuedAt}>{elapsed(item.queuedAt)}</span>
</div>
{#if item.lastError}
<div class="mt-1 text-xs text-rose-600">
Last error: {item.lastError}
</div>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
let {
onScan,
disabled = false
}: {
onScan: (raw: string) => void;
disabled?: boolean;
} = $props();
let value = $state('');
let inputEl: HTMLInputElement | undefined = $state();
function submit() {
const v = value.trim();
if (!v) return;
onScan(v);
value = '';
inputEl?.focus();
}
export function focus() {
inputEl?.focus();
}
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-lg font-semibold text-slate-800">Scan part</h2>
<div class="flex gap-2">
<input
bind:this={inputEl}
bind:value
onkeydown={(e) => e.key === 'Enter' && submit()}
{disabled}
placeholder="Scan barcode or type part code…"
class="flex-1 rounded-md border border-slate-300 bg-white px-4 py-3 text-lg shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none disabled:opacity-50"
/>
<button
type="button"
onclick={submit}
{disabled}
class="rounded-md bg-brand-500 px-5 py-3 font-medium text-white shadow-sm hover:bg-brand-600 disabled:opacity-50"
>
Process
</button>
</div>
</section>
@@ -0,0 +1,62 @@
<script lang="ts">
import { app } from '$lib/stores/app.svelte';
import Stat from './Stat.svelte';
let {
onStart,
onEnd
}: {
onStart: () => void;
onEnd: () => void;
} = $props();
const session = $derived(app.session);
const active = $derived(session != null && session.endedAt == null);
</script>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-800">Scan session</h2>
{#if session}
<p class="text-xs text-slate-500">
{active ? 'Active' : 'Ended'} — started
{new Date(session.startedAt).toLocaleTimeString()}
{#if session.endedAt}
, ended {new Date(session.endedAt).toLocaleTimeString()}
{/if}
</p>
{:else}
<p class="text-xs text-slate-500">No session yet</p>
{/if}
</div>
<div class="flex gap-2">
{#if active}
<button
type="button"
onclick={onEnd}
class="rounded-md border border-rose-300 bg-white px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50"
>
End session
</button>
{:else}
<button
type="button"
onclick={onStart}
class="rounded-md bg-brand-500 px-3 py-1.5 text-sm text-white shadow-sm hover:bg-brand-600"
>
Start new session
</button>
{/if}
</div>
</div>
{#if session}
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<Stat label="Scanned" value={session.stats.scanned} tone="neutral" />
<Stat label="Succeeded" value={session.stats.succeeded} tone="ok" />
<Stat label="Pending" value={session.stats.pending} tone="warn" />
<Stat label="Failed" value={session.stats.failed} tone="err" />
</div>
{/if}
</section>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
label,
value,
tone = 'neutral'
}: {
label: string;
value: number;
tone?: 'neutral' | 'ok' | 'warn' | 'err';
} = $props();
const toneClasses: Record<string, string> = {
neutral: 'border-slate-200 bg-slate-50 text-slate-800',
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
warn: 'border-amber-200 bg-amber-50 text-amber-700',
err: 'border-rose-200 bg-rose-50 text-rose-700'
};
</script>
<div class="rounded-md border p-3 {toneClasses[tone]}">
<div class="text-xs uppercase tracking-wide opacity-70">{label}</div>
<div class="text-2xl font-bold">{value}</div>
</div>
+2
View File
@@ -0,0 +1,2 @@
export { parseScan, interpretCommand, parseLocationBarcode } from '$lib/barcode';
export type { BarcodeCommand } from '$lib/barcode';
+54
View File
@@ -0,0 +1,54 @@
import { env } from '$env/dynamic/private';
interface Config {
host: string;
token: string;
importConcurrency: number;
importTimeoutMs: number;
importBin: string;
}
function int(value: string | undefined, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
let cached: Config | null = null;
function compute(): Config {
const host = env.INVENTREE_HOST;
const token = env.INVENTREE_TOKEN;
if (!host) throw new Error('Missing required env var: INVENTREE_HOST');
if (!token) throw new Error('Missing required env var: INVENTREE_TOKEN');
return {
host: host.replace(/\/$/, ''),
token,
importConcurrency: int(env.IMPORT_CONCURRENCY, 3),
importTimeoutMs: int(env.IMPORT_TIMEOUT_SEC, 60) * 1000,
importBin: env.INVENTREE_PART_IMPORT_BIN || 'inventree-part-import'
};
}
// Lazy proxy so importing this module never touches env; the check runs on
// first property access (inside a request handler, not during build analysis).
export const config = new Proxy({} as Config, {
get(_target, prop: keyof Config) {
cached ??= compute();
return cached[prop];
}
});
export const authHeaders = new Proxy({} as Record<string, string>, {
get(_target, prop: string) {
cached ??= compute();
if (prop === 'Authorization') return `Token ${cached.token}`;
if (prop === 'Content-Type') return 'application/json';
return undefined;
},
ownKeys() {
return ['Authorization', 'Content-Type'];
},
getOwnPropertyDescriptor() {
return { enumerable: true, configurable: true };
}
});
+44
View File
@@ -0,0 +1,44 @@
import { EventEmitter } from 'node:events';
import type { ServerEvent } from '$lib/types';
class Bus extends EventEmitter {
publish(event: ServerEvent): void {
this.emit('event', event);
}
}
export const bus = new Bus();
bus.setMaxListeners(100);
export function subscribe(listener: (e: ServerEvent) => void): () => void {
bus.on('event', listener);
return () => bus.off('event', listener);
}
export function sseStream(): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null;
return new ReadableStream({
start(controller) {
const send = (event: ServerEvent) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
// stream closed
}
};
send({ type: 'heartbeat', at: new Date().toISOString() });
unsubscribe = subscribe(send);
heartbeat = setInterval(() => {
send({ type: 'heartbeat', at: new Date().toISOString() });
}, 20_000);
},
cancel() {
unsubscribe?.();
if (heartbeat) clearInterval(heartbeat);
}
});
}
+194
View File
@@ -0,0 +1,194 @@
import { spawn } from 'node:child_process';
import { config } from './env';
import { bus } from './events';
import { findPart } from './inventree';
export interface ImportResult {
partCode: string;
success: boolean;
partId: number | null;
error: string | null;
}
type Resolver = (r: ImportResult) => void;
interface QueueItem {
partCode: string;
attempts: number;
sessionId: string | null;
resolvers: Resolver[];
}
const MAX_ATTEMPTS = 3;
class ImportQueue {
private queue: QueueItem[] = [];
private pending = new Map<string, QueueItem>(); // partCode → item (dedupes)
private activeWorkers = 0;
private started = false;
enqueue(
partCode: string,
opts: { sessionId?: string | null } = {}
): Promise<ImportResult> {
this.start();
const existing = this.pending.get(partCode);
if (existing) {
return new Promise((resolve) => existing.resolvers.push(resolve));
}
const item: QueueItem = {
partCode,
attempts: 0,
sessionId: opts.sessionId ?? null,
resolvers: []
};
this.pending.set(partCode, item);
this.queue.push(item);
bus.publish({ type: 'import_queued', partCode, sessionId: item.sessionId });
const promise = new Promise<ImportResult>((resolve) => item.resolvers.push(resolve));
this.pump();
return promise;
}
private start(): void {
this.started = true;
}
private pump(): void {
while (this.activeWorkers < config.importConcurrency && this.queue.length > 0) {
const item = this.queue.shift();
if (!item) break;
this.activeWorkers++;
this.work(item).finally(() => {
this.activeWorkers--;
this.pump();
});
}
}
private async work(item: QueueItem): Promise<void> {
item.attempts++;
const attemptResult = await this.runImport(item.partCode);
if (attemptResult.success && attemptResult.partId !== null) {
this.pending.delete(item.partCode);
bus.publish({
type: 'import_complete',
partCode: item.partCode,
partId: attemptResult.partId,
sessionId: item.sessionId
});
for (const r of item.resolvers) r(attemptResult);
return;
}
if (item.attempts < MAX_ATTEMPTS) {
bus.publish({
type: 'import_retry',
partCode: item.partCode,
attempts: item.attempts,
error: attemptResult.error ?? 'unknown error',
sessionId: item.sessionId
});
this.queue.push(item);
return;
}
this.pending.delete(item.partCode);
bus.publish({
type: 'import_failed',
partCode: item.partCode,
error: attemptResult.error ?? 'unknown error',
sessionId: item.sessionId
});
for (const r of item.resolvers) r(attemptResult);
}
private async runImport(partCode: string): Promise<ImportResult> {
const spawnResult = await spawnImport(partCode);
// Always probe the server — `inventree-part-import` can create the part
// and then fail late on a secondary step (e.g. a duplicate parameter
// template). If the part is there, the import is usable regardless of
// the subprocess exit code.
let partId: number | null = null;
let lookupError: string | null = null;
try {
partId = await findPart(partCode);
} catch (e) {
lookupError = e instanceof Error ? e.message : String(e);
}
if (partId !== null) {
return { partCode, success: true, partId, error: null };
}
if (!spawnResult.ok) {
return { partCode, success: false, partId: null, error: spawnResult.error };
}
return {
partCode,
success: false,
partId: null,
error: lookupError ?? 'Imported but part not found on server'
};
}
isStarted(): boolean {
return this.started;
}
stats() {
return {
queued: this.queue.length,
inFlight: this.activeWorkers,
pending: this.pending.size
};
}
}
async function spawnImport(
partCode: string
): Promise<{ ok: true } | { ok: false; error: string }> {
return await new Promise((resolve) => {
const child = spawn(config.importBin, [partCode], {
stdio: ['ignore', 'pipe', 'pipe']
});
let stderr = '';
let settled = false;
const finish = (result: { ok: true } | { ok: false; error: string }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// ignore
}
finish({ ok: false, error: `Import timeout (${config.importTimeoutMs / 1000}s)` });
}, config.importTimeoutMs);
child.stderr?.on('data', (d) => {
stderr += d.toString();
});
child.on('error', (err) => {
finish({ ok: false, error: `Failed to spawn ${config.importBin}: ${err.message}` });
});
child.on('close', (code) => {
if (code === 0) finish({ ok: true });
else
finish({
ok: false,
error: `Import exited ${code}: ${stderr.trim().slice(-300) || 'no stderr'}`
});
});
});
}
export const importQueue = new ImportQueue();
+195
View File
@@ -0,0 +1,195 @@
import { authHeaders, config } from './env';
import type { Location, PartInfo, PartParameter, StockItem } from '$lib/types';
async function req<T>(
method: string,
path: string,
{
params,
body,
headers
}: { params?: Record<string, string | number>; body?: unknown; headers?: Record<string, string> } = {}
): Promise<T> {
const url = new URL(config.host + path);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
method,
headers: { ...authHeaders, ...(headers ?? {}) },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`InvenTree ${method} ${path}${res.status}: ${text.slice(0, 300)}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
function unwrapResults<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
if (data && typeof data === 'object' && 'results' in data) {
const r = (data as { results: unknown }).results;
return Array.isArray(r) ? (r as T[]) : [];
}
return [];
}
export async function getLocations(): Promise<Location[]> {
const data = await req<unknown>('GET', '/api/stock/location/');
return unwrapResults<{ id?: number; pk?: number; name?: string; pathstring?: string }>(data).map(
(loc) => ({
id: (loc.id ?? loc.pk) as number,
name: loc.name ?? '',
pathstring: loc.pathstring
})
);
}
export async function getLocationDetails(id: number): Promise<Location> {
const data = await req<{ id?: number; pk?: number; name?: string; pathstring?: string }>(
'GET',
`/api/stock/location/${id}/`
);
return {
id: (data.id ?? data.pk) as number,
name: data.name ?? '',
pathstring: data.pathstring
};
}
export async function findPart(partCode: string): Promise<number | null> {
const data = await req<unknown>('GET', '/api/part/', { params: { search: partCode } });
const results = unwrapResults<{ pk?: number; id?: number }>(data);
if (!results.length) return null;
return (results[0].pk ?? results[0].id) ?? null;
}
export async function searchParts(
query: string,
limit = 10
): Promise<Array<{ pk: number; IPN: string | null; name: string }>> {
const data = await req<unknown>('GET', '/api/part/', {
params: { search: query, limit }
});
return unwrapResults<{ pk?: number; id?: number; IPN?: string | null; name?: string }>(data).map(
(p) => ({
pk: (p.pk ?? p.id) as number,
IPN: p.IPN ?? null,
name: p.name ?? ''
})
);
}
export async function getPartInfo(partId: number): Promise<PartInfo> {
return req<PartInfo>('GET', `/api/part/${partId}/`);
}
export async function getPartParameters(partId: number): Promise<PartParameter[]> {
const data = await req<unknown>('GET', '/api/part/parameter/', {
params: { part: partId }
});
return unwrapResults<PartParameter>(data);
}
export async function getPartStock(partId: number): Promise<StockItem[]> {
const data = await req<unknown>('GET', '/api/stock/', { params: { part: partId } });
return unwrapResults<StockItem>(data);
}
export async function getPartLocationsSummary(partId: number): Promise<{
locations: Array<{
locationId: number;
name: string;
path: string;
quantity: number;
}>;
total: number;
}> {
const items = await getPartStock(partId);
let total = 0;
const out = [] as Array<{
locationId: number;
name: string;
path: string;
quantity: number;
}>;
for (const item of items) {
const qty = Number(item.quantity) || 0;
total += qty;
if (item.location == null) continue;
try {
const loc = await getLocationDetails(item.location);
out.push({
locationId: item.location,
name: loc.name,
path: loc.pathstring ?? '',
quantity: qty
});
} catch {
out.push({
locationId: item.location,
name: `Location ${item.location}`,
path: '',
quantity: qty
});
}
}
return { locations: out, total };
}
export async function findStockItem(
partId: number,
locationId: number
): Promise<StockItem | null> {
const data = await req<unknown>('GET', '/api/stock/', {
params: { part: partId, location: locationId }
});
const results = unwrapResults<StockItem>(data);
return results[0] ?? null;
}
export async function getStockLevel(partId: number, locationId: number): Promise<number> {
const item = await findStockItem(partId, locationId);
return item ? Number(item.quantity) : 0;
}
export async function createStockItem(
partId: number,
locationId: number,
quantity: number
): Promise<StockItem> {
const resp = await req<unknown>('POST', '/api/stock/', {
body: { part: partId, location: locationId, quantity }
});
const item = Array.isArray(resp) ? resp[0] : resp;
return item as StockItem;
}
export async function patchStockQuantity(stockItemId: number, quantity: number): Promise<void> {
await req('PATCH', `/api/stock/${stockItemId}/`, { body: { quantity } });
}
export async function fetchImage(
url: string
): Promise<{ body: ArrayBuffer; contentType: string }> {
const absolute = url.startsWith('/') ? config.host + url : url;
const res = await fetch(absolute, {
headers: { Authorization: `Token ${config.token}` }
});
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
return {
body: await res.arrayBuffer(),
contentType: res.headers.get('content-type') ?? 'image/jpeg'
};
}
export async function ping(): Promise<boolean> {
try {
const res = await fetch(config.host + '/api/', { headers: authHeaders });
return res.ok;
} catch {
return false;
}
}
+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 };
+72
View File
@@ -0,0 +1,72 @@
import type {
Location,
ScanMode,
ScanSession,
ServerEvent
} from '$lib/types';
interface LogEntry {
id: number;
time: string;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
}
class AppState {
host = $state('');
modes: Record<string, string> = $state({});
importConcurrency = $state(3);
locations = $state<Location[]>([]);
session = $state<ScanSession | null>(null);
connected = $state(false);
lastScanAt = $state<string | null>(null);
logs = $state<LogEntry[]>([]);
private nextLogId = 1;
private maxLogs = 200;
log(type: LogEntry['type'], message: string): void {
const entry: LogEntry = {
id: this.nextLogId++,
time: new Date().toLocaleTimeString(),
type,
message
};
this.logs = [...this.logs, entry].slice(-this.maxLogs);
}
setSession(session: ScanSession | null): void {
this.session = session;
}
handleServerEvent(event: ServerEvent): void {
switch (event.type) {
case 'heartbeat':
this.connected = true;
break;
case 'session_update':
if (this.session && event.session.id === this.session.id) {
this.session = event.session;
}
break;
case 'import_queued':
this.log('info', `Queued for import: ${event.partCode}`);
break;
case 'import_complete':
this.log('success', `Imported: ${event.partCode}`);
break;
case 'import_retry':
this.log(
'warning',
`Import retry ${event.attempts}/3: ${event.partCode}${event.error}`
);
break;
case 'import_failed':
this.log('error', `Import failed: ${event.partCode}${event.error}`);
break;
}
}
}
export const app = new AppState();
+131
View File
@@ -0,0 +1,131 @@
export type ScanMode = 'import' | 'update' | 'get' | 'locate';
export const MODE_NAMES: Record<ScanMode, string> = {
import: 'Add Stock',
update: 'Update Stock',
get: 'Check Stock',
locate: 'Locate Part'
};
export const BARCODE_COMMANDS = {
import: ['MODE:ADD', 'MODE:IMPORT', 'ADD_STOCK', 'IMPORT'],
update: ['MODE:UPDATE', 'UPDATE_STOCK', 'UPDATE'],
get: ['MODE:CHECK', 'MODE:GET', 'CHECK_STOCK', 'CHECK'],
locate: ['MODE:LOCATE', 'LOCATE_PART', 'LOCATE', 'FIND_PART'],
debug_on: ['DEBUG:ON', 'DEBUG_ON'],
debug_off: ['DEBUG:OFF', 'DEBUG_OFF'],
change_location: ['CHANGE_LOCATION', 'NEW_LOCATION', 'SET_LOCATION', 'LOCATION']
} as const;
export interface Location {
id: number;
name: string;
pathstring?: string;
}
export interface PartInfo {
pk: number;
IPN: string | null;
name: string;
description: string | null;
image: string | null;
thumbnail: string | null;
category: number | null;
[key: string]: unknown;
}
export interface PartParameter {
pk: number;
data: string | null;
template_detail?: { name: string; units?: string };
}
export interface StockItem {
pk: number;
part: number;
location: number | null;
quantity: number;
}
export interface PartLocation {
location_id: number;
location_name: string;
location_path: string;
quantity: number;
}
export type FailureReason =
| 'unknown_part'
| 'import_failed'
| 'parse_failed'
| 'missing_quantity'
| 'invalid_location'
| 'api_error';
export interface FailedScan {
id: string;
rawBarcode: string;
parsedPartCode: string | null;
parsedQuantity: number | null;
reason: FailureReason;
message: string;
timestamp: string;
attempts: number;
suggestions?: Array<{ pk: number; IPN: string | null; name: string }>;
}
export interface PendingImport {
id: string;
partCode: string;
queuedAt: string;
attempts: number;
quantity: number | null;
locationId: number | null;
mode: ScanMode;
lastError: string | null;
}
export interface ScanSession {
id: string;
startedAt: string;
endedAt: string | null;
mode: ScanMode;
locationId: number | null;
stats: {
scanned: number;
succeeded: number;
failed: number;
pending: number;
};
failures: FailedScan[];
pendingImports: PendingImport[];
}
export interface ParsedBarcode {
partCode: string | null;
quantity: number | null;
}
export type ServerEvent =
| { type: 'heartbeat'; at: string }
| { type: 'session_update'; session: ScanSession }
| { type: 'import_queued'; partCode: string; sessionId: string | null }
| {
type: 'import_complete';
partCode: string;
partId: number;
sessionId: string | null;
}
| {
type: 'import_retry';
partCode: string;
attempts: number;
error: string;
sessionId: string | null;
}
| {
type: 'import_failed';
partCode: string;
error: string;
sessionId: string | null;
};
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+217
View File
@@ -0,0 +1,217 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app } from '$lib/stores/app.svelte';
import { api, failuresToCsv, type RetryFixes } from '$lib/client';
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
import LocationPicker from '$lib/components/LocationPicker.svelte';
import ModeSelector from '$lib/components/ModeSelector.svelte';
import ScanInput from '$lib/components/ScanInput.svelte';
import SessionPanel from '$lib/components/SessionPanel.svelte';
import PendingList from '$lib/components/PendingList.svelte';
import FailureList from '$lib/components/FailureList.svelte';
import FailureFixDialog from '$lib/components/FailureFixDialog.svelte';
import ActivityLog from '$lib/components/ActivityLog.svelte';
import { interpretCommand, parseLocationBarcode } from '$lib/barcode';
import type { FailedScan, ScanMode } from '$lib/types';
let fixing = $state<FailedScan | null>(null);
let es: EventSource | null = null;
let awaitingLocationChange = $state(false);
onMount(() => {
bootstrap();
return () => {
es?.close();
};
});
async function bootstrap() {
try {
const cfg = await api.config();
app.host = cfg.host;
app.modes = cfg.modes;
app.importConcurrency = cfg.importConcurrency;
const locs = await api.locations();
app.locations = locs.locations;
await startNewSession();
connectSSE();
app.log('success', 'Ready to scan');
} catch (e) {
app.log('error', `Init failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
function connectSSE() {
es = new EventSource('/api/events');
es.onmessage = (ev) => {
try {
const event = JSON.parse(ev.data);
app.handleServerEvent(event);
} catch {
// ignore malformed
}
};
es.onerror = () => {
app.connected = false;
};
}
async function startNewSession() {
const mode: ScanMode = app.session?.mode ?? 'import';
const locationId = app.session?.locationId ?? null;
const { session } = await api.startSession(mode, locationId);
app.setSession(session);
app.log('info', `New session started (${session.id.slice(0, 8)})`);
}
async function endSession() {
if (!app.session) return;
const { session } = await api.endSession(app.session.id);
app.setSession(session);
app.log('info', 'Session ended');
}
async function handleModeChange(mode: ScanMode) {
if (!app.session) return;
const { session } = await api.setSessionMode(app.session.id, mode);
app.setSession(session);
app.log('info', `Mode: ${app.modes[mode] ?? mode}`);
}
async function handleLocationChange(locationId: number | null) {
if (!app.session) return;
const { session } = await api.setSessionLocation(app.session.id, locationId);
app.setSession(session);
if (locationId != null) {
const loc = app.locations.find((l) => l.id === locationId);
app.log('info', `Location: ${loc ? `${loc.id}${loc.name}` : `#${locationId}`}`);
}
awaitingLocationChange = false;
}
async function handleScan(raw: string) {
if (!app.session || app.session.endedAt) await startNewSession();
if (!app.session) return;
if (awaitingLocationChange) {
const id = parseLocationBarcode(raw);
if (id != null) await handleLocationChange(id);
else app.log('warning', `Could not parse location from: ${raw}`);
return;
}
const cmd = interpretCommand(raw);
if (cmd?.type === 'mode') {
await handleModeChange(cmd.mode);
return;
}
if (cmd?.type === 'change_location') {
awaitingLocationChange = true;
app.log('info', 'Scan new location…');
return;
}
try {
const { outcome, session } = await api.scan(app.session.id, raw);
if (session) app.setSession(session);
app.lastScanAt = new Date().toISOString();
if (outcome.outcome === 'ok') app.log('success', outcome.message);
else if (outcome.outcome === 'pending_import')
app.log('info', `Queued import: ${outcome.partCode}`);
else app.log('error', `${outcome.failure.reason}: ${outcome.failure.message}`);
} catch (e) {
app.log('error', `Scan error: ${e instanceof Error ? e.message : String(e)}`);
}
}
function handleFix(f: FailedScan) {
fixing = f;
}
async function submitFix(fixes: RetryFixes) {
if (!app.session || !fixing) return;
const current = fixing;
fixing = null;
try {
const { outcome, session } = await api.retry(app.session.id, current.id, fixes);
if (session) app.setSession(session);
if (outcome.outcome === 'ok') app.log('success', `Fixed: ${outcome.message}`);
else if (outcome.outcome === 'pending_import')
app.log('info', `Re-queued import: ${outcome.partCode}`);
else app.log('error', `Retry failed: ${outcome.failure.message}`);
} catch (e) {
app.log('error', `Retry error: ${e instanceof Error ? e.message : String(e)}`);
}
}
async function handleDismiss(failureId: string) {
if (!app.session) return;
const { session } = await api.dismissFailure(app.session.id, failureId);
app.setSession(session);
}
function handleExport() {
if (!app.session) return;
const csv = failuresToCsv(app.session.failures);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `failures-${app.session.id.slice(0, 8)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div class="min-h-screen">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<h1 class="text-xl font-bold text-slate-900">InvenTree Stock Tool</h1>
<ConnectionStatus />
</div>
</header>
<main class="mx-auto max-w-7xl space-y-4 px-4 py-6">
<SessionPanel onStart={startNewSession} onEnd={endSession} />
{#if awaitingLocationChange}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Waiting for next scan to be interpreted as a location…
<button
type="button"
class="ml-2 underline"
onclick={() => (awaitingLocationChange = false)}>cancel</button
>
</div>
{/if}
<div class="grid gap-4 lg:grid-cols-2">
<!-- Left column: scanner + recent activity -->
<div class="space-y-4">
<LocationPicker onChange={handleLocationChange} />
<ModeSelector onChange={handleModeChange} />
<ScanInput onScan={handleScan} disabled={!app.session || !!app.session.endedAt} />
<ActivityLog />
</div>
<!-- Right column: queue -->
<div class="space-y-4">
<PendingList />
<FailureList onFix={handleFix} onDismiss={handleDismiss} onExport={handleExport} />
</div>
</div>
</main>
{#if fixing}
{#key fixing.id}
<FailureFixDialog
failure={fixing}
onClose={() => (fixing = null)}
onSubmit={submitFix}
/>
{/key}
{/if}
</div>
+13
View File
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { config } from '$lib/server/env';
import { BARCODE_COMMANDS, MODE_NAMES } from '$lib/types';
export const GET: RequestHandler = () => {
return json({
host: config.host,
modes: MODE_NAMES,
barcodeCommands: BARCODE_COMMANDS,
importConcurrency: config.importConcurrency
});
};
+13
View File
@@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { sseStream } from '$lib/server/events';
export const GET: RequestHandler = () => {
return new Response(sseStream(), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getLocations } from '$lib/server/inventree';
export const GET: RequestHandler = async () => {
try {
const locations = await getLocations();
return json({ locations });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(502, msg);
}
};
+19
View File
@@ -0,0 +1,19 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { searchParts } from '$lib/server/inventree';
export const POST: RequestHandler = async ({ request }) => {
const { query, limit } = (await request.json().catch(() => ({}))) as {
query?: string;
limit?: number;
};
if (!query) error(400, 'query required');
try {
const results = await searchParts(query, limit ?? 10);
return json({ results });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(502, msg);
}
};
+21
View File
@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { fetchImage } from '$lib/server/inventree';
export const GET: RequestHandler = async ({ url }) => {
const target = url.searchParams.get('url');
if (!target) error(400, 'url parameter required');
try {
const { body, contentType } = await fetchImage(target);
return new Response(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=300'
}
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(502, msg);
}
};
+11
View File
@@ -0,0 +1,11 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { endSession } from '$lib/server/sessions';
export const POST: RequestHandler = async ({ request }) => {
const { sessionId } = (await request.json().catch(() => ({}))) as { sessionId?: string };
if (!sessionId) error(400, 'sessionId required');
const session = endSession(sessionId);
if (!session) error(404, 'session not found');
return json({ session });
};
@@ -0,0 +1,22 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dismissFailure, getSession } from '$lib/server/sessions';
export const GET: RequestHandler = ({ url }) => {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) error(400, 'sessionId required');
const session = getSession(sessionId);
if (!session) error(404, 'session not found');
return json({ failures: session.failures });
};
export const DELETE: RequestHandler = async ({ request }) => {
const { sessionId, failureId } = (await request.json().catch(() => ({}))) as {
sessionId?: string;
failureId?: string;
};
if (!sessionId || !failureId) error(400, 'sessionId and failureId required');
const session = dismissFailure(sessionId, failureId);
if (!session) error(404, 'session not found');
return json({ session });
};
@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateSessionLocation } from '$lib/server/sessions';
export const POST: RequestHandler = async ({ request }) => {
const { sessionId, locationId } = (await request.json().catch(() => ({}))) as {
sessionId?: string;
locationId?: number | null;
};
if (!sessionId) error(400, 'sessionId required');
const session = updateSessionLocation(sessionId, locationId ?? null);
if (!session) error(404, 'session not found');
return json({ session });
};
@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateSessionMode } from '$lib/server/sessions';
import type { ScanMode } from '$lib/types';
const VALID: ScanMode[] = ['import', 'update', 'get', 'locate'];
export const POST: RequestHandler = async ({ request }) => {
const { sessionId, mode } = (await request.json().catch(() => ({}))) as {
sessionId?: string;
mode?: ScanMode;
};
if (!sessionId) error(400, 'sessionId required');
if (!mode || !VALID.includes(mode)) error(400, 'valid mode required');
const session = updateSessionMode(sessionId, mode);
if (!session) error(404, 'session not found');
return json({ session });
};
@@ -0,0 +1,21 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { retryFailure, getSession, type RetryFixes } from '$lib/server/sessions';
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as {
sessionId?: string;
failureId?: string;
fixes?: RetryFixes;
};
if (!body.sessionId) error(400, 'sessionId required');
if (!body.failureId) error(400, 'failureId required');
try {
const outcome = await retryFailure(body.sessionId, body.failureId, body.fixes ?? {});
return json({ outcome, session: getSession(body.sessionId) });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(400, msg);
}
};
@@ -0,0 +1,30 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processScan, getSession } from '$lib/server/sessions';
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as {
sessionId?: string;
rawBarcode?: string;
partCode?: string | null;
quantity?: number | null;
locationId?: number | null;
partId?: number | null;
};
if (!body.sessionId) error(400, 'sessionId required');
if (body.rawBarcode == null) error(400, 'rawBarcode required');
try {
const outcome = await processScan(body.sessionId, {
rawBarcode: body.rawBarcode,
partCode: body.partCode ?? null,
quantity: body.quantity ?? null,
locationId: body.locationId ?? null,
partId: body.partId ?? null
});
return json({ outcome, session: getSession(body.sessionId) });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(400, msg);
}
};
@@ -0,0 +1,21 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createSession } from '$lib/server/sessions';
import type { ScanMode } from '$lib/types';
const VALID_MODES: ScanMode[] = ['import', 'update', 'get', 'locate'];
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as {
mode?: ScanMode;
locationId?: number | null;
};
const mode = body.mode ?? 'import';
if (!VALID_MODES.includes(mode)) error(400, `invalid mode: ${mode}`);
const session = createSession({
mode,
locationId: body.locationId ?? null
});
return json({ session });
};
+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});