946e96c3ea
Build and push image / build (push) Successful in 1m3s
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>
452 lines
14 KiB
TypeScript
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" });
|
|
}
|
|
});
|