Files
Apothecary/server/src/routes/inventory.ts
T
josh 946e96c3ea
Build and push image / build (push) Successful in 1m3s
Add bulk editing to inventory tab with atomic batch API
Multi-select inventory items via checkboxes (select-all, shift-click
range, group header select) and apply bulk actions through a floating
toolbar: edit fields (shop, bin, price, THC/CBD), consume, checkout,
check in, and mark gone. Backend processes all operations in a single
SQLite transaction for atomicity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 22:14:01 -04:00

452 lines
14 KiB
TypeScript

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" });
}
});