import { Router } from "express"; import { ASSET_ID_RE, db, nextId } from "../db.js"; export const inventoryRouter: Router = Router(); type CreateBody = { assetId: string; productId: string; shopId?: string | null; binId?: string | null; price: number; thc?: number; cbd?: number; totalCannabinoids?: number; weight?: number; countOriginal?: number; unitWeight?: number; purchaseDate: string; }; inventoryRouter.post("/inventory", (req, res) => { const body = req.body as CreateBody; if (!body.productId) return res.status(400).json({ error: "productId required" }); if (!body.purchaseDate) return res.status(400).json({ error: "purchaseDate required" }); if (!Number.isFinite(body.price) || body.price < 0) { return res.status(400).json({ error: "price required" }); } const assetId = (body.assetId ?? "").trim(); if (!ASSET_ID_RE.test(assetId)) { return res.status(400).json({ error: "assetId must be exactly 6 digits" }); } const taken = db .prepare<[string], { id: string }>( `SELECT id FROM inventory_items WHERE asset_id = ?`, ) .get(assetId); if (taken) { return res.status(409).json({ error: "asset id already in use", id: taken.id }); } const product = db .prepare<[string], { id: string; kind: string }>( `SELECT id, kind FROM products WHERE id = ?`, ) .get(body.productId); if (!product) return res.status(404).json({ error: "product not found" }); const isDiscrete = product.kind === "discrete"; const id = nextId("inv", "inventory_items"); db.prepare( `INSERT INTO inventory_items ( id, asset_id, product_id, shop_id, bin_id, price, thc, cbd, total_cannabinoids, weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date, status ) VALUES ( @id, @assetId, @productId, @shopId, @binId, @price, @thc, @cbd, @totalCannabinoids, @weight, @lastAuditWeight, @countOriginal, @countLastAudit, @unitWeight, @purchaseDate, 'active' )`, ).run({ id, assetId, productId: body.productId, shopId: body.shopId ?? null, binId: body.binId ?? null, price: body.price, thc: body.thc ?? 0, cbd: body.cbd ?? 0, totalCannabinoids: body.totalCannabinoids ?? 0, weight: isDiscrete ? 0 : body.weight ?? 0, lastAuditWeight: isDiscrete ? null : body.weight ?? 0, countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0, countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null, unitWeight: isDiscrete ? body.unitWeight ?? 0 : 0, purchaseDate: body.purchaseDate, }); res.json({ id, assetId }); }); // ─── Shared helpers (used by individual routes + batch) ─────────── type UpdateBody = Partial<{ shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number; weight: number; countOriginal: number; unitWeight: number; purchaseDate: string; }>; type ItemRow = { id: string; shop_id: string | null; bin_id: string | null; product_id: string; price: number; thc: number; cbd: number; total_cannabinoids: number; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null; unit_weight: number; purchase_date: string; }; function doUpdate(id: string, body: UpdateBody): void { const existing = db .prepare<[string], ItemRow>( `SELECT id, shop_id, bin_id, product_id, price, thc, cbd, total_cannabinoids, weight, last_audit_weight, count_original, count_last_audit, unit_weight, purchase_date FROM inventory_items WHERE id = ?`, ) .get(id); if (!existing) throw new Error("inventory item not found"); const product = db .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) .get(existing.product_id); const isDiscrete = product?.kind === "discrete"; const auditCount = db .prepare<[string], { n: number }>( `SELECT COUNT(*) AS n FROM audits WHERE inventory_id = ?`, ) .get(id)!.n; const nextShopId = body.shopId === undefined ? existing.shop_id : body.shopId || null; const nextBinId = body.binId === undefined ? existing.bin_id : body.binId || null; const nextPrice = Number.isFinite(body.price) && (body.price as number) >= 0 ? (body.price as number) : existing.price; const nextPurchaseDate = typeof body.purchaseDate === "string" && body.purchaseDate.trim() ? body.purchaseDate.trim() : existing.purchase_date; const nextThc = Number.isFinite(body.thc) ? (body.thc as number) : existing.thc; const nextCbd = Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd; const nextTotalCanna = Number.isFinite(body.totalCannabinoids) ? (body.totalCannabinoids as number) : existing.total_cannabinoids; const nextWeight = !isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0 ? (body.weight as number) : existing.weight; const nextCountOriginal = isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0 ? Math.floor(body.countOriginal as number) : existing.count_original; const nextUnitWeight = isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0 ? (body.unitWeight as number) : existing.unit_weight; const nextLastAuditWeight = !isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight; const nextCountLastAudit = isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit; db.prepare( `UPDATE inventory_items SET shop_id = @shopId, bin_id = @binId, price = @price, thc = @thc, cbd = @cbd, total_cannabinoids = @totalCannabinoids, weight = @weight, last_audit_weight = @lastAuditWeight, count_original = @countOriginal, count_last_audit = @countLastAudit, unit_weight = @unitWeight, purchase_date = @purchaseDate WHERE id = @id`, ).run({ id, shopId: nextShopId, binId: nextBinId, price: nextPrice, thc: nextThc, cbd: nextCbd, totalCannabinoids: nextTotalCanna, weight: nextWeight, lastAuditWeight: nextLastAuditWeight, countOriginal: nextCountOriginal, countLastAudit: nextCountLastAudit, unitWeight: nextUnitWeight, purchaseDate: nextPurchaseDate, }); } function doCheckout(id: string, date: string): void { const result = db .prepare( `UPDATE inventory_items SET status = 'checked-out', checkout_date = ?, bin_id = NULL WHERE id = ? AND status = 'active'`, ) .run(date, id); if (result.changes === 0) throw new Error("not found or not active"); } function doCheckin(id: string, date: string, binId: string, remainingWeight?: number): void { const item = db .prepare< [string], { product_id: string; last_audit_weight: number | null; weight: number } >( `SELECT product_id, last_audit_weight, weight FROM inventory_items WHERE id = ? AND status = 'checked-out'`, ) .get(id); if (!item) throw new Error("not found or not checked-out"); const product = db .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) .get(item.product_id); const isBulk = product?.kind === "bulk"; db.prepare( `UPDATE inventory_items SET status = 'active', bin_id = ?, checkout_date = NULL WHERE id = ?`, ).run(binId, id); if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) { const prev = item.last_audit_weight ?? item.weight; db.prepare( `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, 'weigh', ?, ?, 'checkin')`, ).run(id, date, remainingWeight, prev); db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run( remainingWeight, id, ); } } function doFinish(id: string, date: string, rating?: number, notes?: string): void { const result = db .prepare( `UPDATE inventory_items SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL, checkout_date = NULL WHERE id = ? AND status IN ('active', 'checked-out')`, ) .run(date, rating ?? null, notes ?? null, id); if (result.changes === 0) throw new Error("not found or not active"); } function doGone(id: string, date: string, reason: string, notes?: string): void { const combinedNotes = notes ? `${reason}: ${notes}` : reason; const result = db .prepare( `UPDATE inventory_items SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL WHERE id = ? AND status IN ('active', 'checked-out')`, ) .run(date, combinedNotes, id); if (result.changes === 0) throw new Error("not found or not active"); db.prepare( `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, 'presence', 0, NULL, 'lost')`, ).run(id, date); } // ─── Individual routes (thin wrappers around helpers) ───────────── inventoryRouter.patch("/inventory/:id", (req, res) => { try { doUpdate(req.params.id, req.body as UpdateBody); res.json({ ok: true }); } catch (e: any) { res.status(404).json({ error: e.message }); } }); inventoryRouter.post("/inventory/:id/checkout", (req, res) => { try { doCheckout(req.params.id, (req.body as { date: string }).date); res.json({ ok: true }); } catch (e: any) { res.status(404).json({ error: e.message }); } }); inventoryRouter.post("/inventory/:id/checkin", (req, res) => { const { date, binId, remainingWeight } = req.body as { date: string; binId: string; remainingWeight?: number; }; try { doCheckin(req.params.id, date, binId, remainingWeight); res.json({ ok: true }); } catch (e: any) { res.status(404).json({ error: e.message }); } }); inventoryRouter.post("/inventory/:id/finish", (req, res) => { const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string; }; try { doFinish(req.params.id, date, rating, notes); res.json({ ok: true }); } catch (e: any) { res.status(404).json({ error: e.message }); } }); inventoryRouter.post("/inventory/:id/gone", (req, res) => { const { date, reason, notes } = req.body as { date: string; reason: string; notes?: string; }; try { doGone(req.params.id, date, reason, notes); res.json({ ok: true }); } catch (e: any) { res.status(404).json({ error: e.message }); } }); inventoryRouter.post("/inventory/:id/audit", (req, res) => { const { id } = req.params; const { date, mode, value, confirmedBy } = req.body as { date: string; mode: "weigh" | "estimate" | "presence"; value: number; confirmedBy?: string; }; const item = db .prepare< [string], { product_id: string; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null; } >( `SELECT product_id, weight, last_audit_weight, count_original, count_last_audit FROM inventory_items WHERE id = ?`, ) .get(id); if (!item) return res.status(404).json({ error: "not found" }); const product = db .prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`) .get(item.product_id); const isDiscrete = product?.kind === "discrete"; const prev = isDiscrete ? item.count_last_audit ?? item.count_original : item.last_audit_weight ?? item.weight; const tx = db.transaction(() => { db.prepare( `INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, ?, ?, ?, ?)`, ).run(id, date, mode, value, prev, confirmedBy ?? null); if (isDiscrete) { db.prepare(`UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`).run( value, id, ); } else { db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run( value, id, ); } }); tx(); res.json({ ok: true }); }); // ─── Batch endpoint ─────────────────────────────────────────────── type BatchOp = | { action: "update"; id: string; fields: UpdateBody } | { action: "checkout"; id: string; date: string } | { action: "checkin"; id: string; date: string; binId: string } | { action: "finish"; id: string; date: string; rating?: number; notes?: string } | { action: "gone"; id: string; date: string; reason: string; notes?: string }; inventoryRouter.post("/inventory/batch", (req, res) => { const { ops } = req.body as { ops: BatchOp[] }; if (!Array.isArray(ops) || ops.length === 0) { return res.status(400).json({ error: "ops array required" }); } if (ops.length > 200) { return res.status(400).json({ error: "too many operations (max 200)" }); } const tx = db.transaction(() => { for (const op of ops) { switch (op.action) { case "update": doUpdate(op.id, op.fields); break; case "checkout": doCheckout(op.id, op.date); break; case "checkin": doCheckin(op.id, op.date, op.binId); break; case "finish": doFinish(op.id, op.date, op.rating, op.notes); break; case "gone": doGone(op.id, op.date, op.reason, op.notes); break; default: throw new Error(`unknown action: ${(op as any).action}`); } } }); try { tx(); res.json({ ok: true, count: ops.length }); } catch (e: any) { res.status(400).json({ error: e.message ?? "batch failed" }); } });