Add mobile batch inventory scanning

This commit is contained in:
mfwadejr
2026-09-20 23:23:51 -04:00
parent 672f4042e7
commit a8fe8147c0
7 changed files with 24 additions and 5 deletions
+3 -1
View File
@@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path";
import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
const APP_VERSION="3.4.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
const APP_VERSION="3.5.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
@@ -90,6 +90,7 @@ async function restoreFrom(path,res,req,user){validateBackup(path);const target=
function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})}
function cleanModelName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60||/[\u0000-\u001f\u007f]/.test(name))throw new Error("Model name must contain 160 characters.");return name}
function productInput(v){const requested=String(v.model||"").trim(),modelRecord=db.prepare("SELECT name FROM product_models WHERE name=? COLLATE NOCASE AND active=1").get(requested),condition=String(v.condition||"");if(!modelRecord||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("An active model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model:modelRecord.name,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
function receiveBatch(req,user,v){const rows=Array.isArray(v.items)?v.items:[],shared={...v};delete shared.items;if(!rows.length)throw new Error("Add at least one scanned device.");if(rows.length>100)throw new Error("A batch can contain no more than 100 devices.");const products=rows.map(row=>productInput({...shared,...row}));const seen=new Set();for(const p of products){for(const key of [p.uid,p.sn,p.mac].filter(Boolean)){const normalized=key.toLowerCase();if(seen.has(normalized)||db.prepare("SELECT 1 FROM products WHERE lower(uid)=? OR lower(sn)=? OR lower(mac)=? LIMIT 1").get(normalized,normalized,normalized))throw new Error(`Duplicate identifier detected: ${key}`);seen.add(normalized)}}const ids=[];db.exec("BEGIN IMMEDIATE");try{const insert=db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)");for(const p of products){const id=crypto.randomUUID();insert.run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,`${p.model}; batch of ${products.length}`);ids.push(id)}db.exec("COMMIT")}catch(error){db.exec("ROLLBACK");throw error}return{count:ids.length,products:ids.map(id=>getProduct().get(id))}}
function modelUsage(id){return db.prepare("SELECT m.id,m.name,m.active,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE WHERE m.id=? GROUP BY m.id").get(id)}
function allModels(){return db.prepare("SELECT m.id,m.name,m.active,m.created_at AS createdAt,m.updated_at AS updatedAt,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE GROUP BY m.id ORDER BY lower(m.name)").all().map(x=>({...x,active:Boolean(x.active)}))}
function cleanWarrantyName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60)throw new Error("Warranty name must contain 160 characters.");return name}
@@ -159,6 +160,7 @@ async function api(req,res,url){
const exportMatch=url.pathname.match(/^\/api\/export\/(inventory|sales|customers|warranties|audit)$/);if(exportMatch&&req.method==="GET"){const type=exportMatch[1];if(type==="inventory"){const rows=listProducts().all().filter(x=>x.status==="available");return csvResponse(res,"vboxstock-inventory.csv",["Model","UID","Serial Number","MAC","Condition","Received","Cost","Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.condition,x.receivedAt,x.cost,x.notes]))}if(type==="sales"){const rows=listProducts().all().filter(x=>x.status==="sold");return csvResponse(res,"vboxstock-sales.csv",["Model","UID","Serial Number","MAC","Customer","Phone","Date Sold","Sale Price","Payment Method","Payment Reference","Fulfillment","Carrier","Tracking Number","Delivery Status","Warranty","Warranty End","Sale Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.customerName,x.phone,x.soldAt,x.salePrice,x.paymentMethod,x.paymentReference,x.fulfillmentMethod,x.carrier,x.trackingNumber,x.deliveryStatus,x.warrantyName,x.warrantyEndDate,x.saleNotes]))}if(type==="customers"){const rows=db.prepare("SELECT name,phone,address1,address2,city,state,zip,shipping_notes FROM customers ORDER BY lower(name)").all();return csvResponse(res,"vboxstock-customers.csv",["Name","Phone","Address 1","Address 2","City","State","ZIP","Notes"],rows.map(Object.values))}if(type==="warranties"){const rows=allWarranties();return csvResponse(res,"vboxstock-warranties.csv",["Name","Duration","Unit","Active","Default","Sales"],rows.map(x=>[x.name,x.durationValue,x.durationUnit,x.active,x.isDefault,x.usageCount]))}if(type==="audit"){if(user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});const rows=db.prepare("SELECT username,action,target,details,ip_address,created_at FROM audit_log ORDER BY id DESC").all();return csvResponse(res,"vboxstock-audit.csv",["Username","Action","Target","Details","IP Address","Created"],rows.map(Object.values))}}
if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().all());
if(url.pathname==="/api/products"&&req.method==="POST"){const p=productInput(await body(req)),id=crypto.randomUUID();db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)").run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,p.model);return json(res,201,getProduct().get(id))}
if(url.pathname==="/api/products/batch"&&req.method==="POST"){return json(res,201,receiveBatch(req,user,await body(req)))}
if(url.pathname==="/api/sales"&&req.method==="POST"){const v=await body(req);return json(res,201,recordSales(req,user,v,v.items))}
const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/);
if(customerMatch&&req.method==="GET"){const id=decodeURIComponent(customerMatch[1]),customer=db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes FROM customers WHERE id=?").get(id);if(!customer)return json(res,404,{error:"Customer not found"});const purchases=db.prepare(`SELECT ${columns} FROM products WHERE status='sold' AND customer_id=? ORDER BY sold_at DESC,rowid DESC`).all(id),notes=db.prepare("SELECT id,category,note,created_at AS createdAt,updated_at AS updatedAt FROM customer_notes WHERE customer_id=? ORDER BY created_at DESC,rowid DESC").all(id);return json(res,200,{...customer,purchases,notes})}