feat: Add SvelteKit web app with scan sessions and import queue
Replaces the Flask/Alpine web app with a SvelteKit 2 + Svelte 5 rewrite under web/, built on adapter-node and Tailwind v4. Same shape as the reference b4l budget app — no auth, stateless pass-through to InvenTree. New "scan session" flow groups mass scans into a session with live counters (scanned / succeeded / pending / failed). Unknown parts in import mode are fed to a worker pool that spawns inventree-part-import (IMPORT_CONCURRENCY, default 3, with 3-retry). Anything that can't be resolved automatically — parse errors, missing qty, invalid location, API errors, or imports that exhaust retries — drops into a Failures panel with a per-item Fix dialog (edit fields / search existing part / retry import). CSV export on the failure list. Layout is two-column on lg+: scanner + activity on the left, pending imports + failures on the right. Light-theme default. SSE on /api/events streams session and import events to the client. Barcode parser ported from the Python/JS versions and hardened for Digi-Key MH10.8.2 barcodes both with and without GS separators (old parser greedy-matched Q's digits and read "Q6" + "11ZPICK" as 611). Import worker also now treats a subprocess failure followed by a successful findPart as a success, so partial imports (part created but a duplicate parameter trips the DB constraint) no longer land in the Failures panel. Deploy artifacts: systemd unit, nginx example (SSE-friendly), and a step-by-step deploy/README. Requires inventree-part-import >= 1.9.2 on the server for InvenTree 1.x API compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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).
|
||||
@@ -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. 3–4 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.
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+2481
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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%;
|
||||
}
|
||||
Vendored
+12
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 & retry</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseScan, interpretCommand, parseLocationBarcode } from '$lib/barcode';
|
||||
export type { BarcodeCommand } from '$lib/barcode';
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
});
|
||||
Reference in New Issue
Block a user