Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s
Build and push image / build (push) Successful in 46s
The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.
- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { Router } from "express";
|
||||
import { db, generateAssetId, nextId } from "../db.js";
|
||||
|
||||
export const inventoryRouter: Router = Router();
|
||||
|
||||
type CreateBody = {
|
||||
productId: string;
|
||||
brandId?: string | null;
|
||||
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 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");
|
||||
const assetId = generateAssetId();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO inventory_items (
|
||||
id, asset_id, product_id,
|
||||
brand_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,
|
||||
@brandId, @shopId, @binId,
|
||||
@price, @thc, @cbd, @totalCannabinoids,
|
||||
@weight, @lastAuditWeight,
|
||||
@countOriginal, @countLastAudit, @unitWeight,
|
||||
@purchaseDate, 'active'
|
||||
)`,
|
||||
).run({
|
||||
id,
|
||||
assetId,
|
||||
productId: body.productId,
|
||||
brandId: body.brandId ?? null,
|
||||
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 });
|
||||
});
|
||||
|
||||
type UpdateBody = Partial<{
|
||||
brandId: string | null;
|
||||
shopId: string | null;
|
||||
binId: string | null;
|
||||
price: number;
|
||||
thc: number;
|
||||
cbd: number;
|
||||
totalCannabinoids: number;
|
||||
weight: number;
|
||||
countOriginal: number;
|
||||
unitWeight: number;
|
||||
purchaseDate: string;
|
||||
}>;
|
||||
|
||||
inventoryRouter.patch("/inventory/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const body = req.body as UpdateBody;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
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;
|
||||
};
|
||||
|
||||
const existing = db
|
||||
.prepare<[string], Row>(
|
||||
`SELECT id, brand_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) return res.status(404).json({ 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 nextBrandId =
|
||||
body.brandId === undefined ? existing.brand_id : body.brandId || null;
|
||||
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;
|
||||
|
||||
// Mirror the original size into the "last audit" field while no audits
|
||||
// exist — keeps the next audit's prev_value accurate after an edit.
|
||||
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
|
||||
brand_id = @brandId,
|
||||
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,
|
||||
brandId: nextBrandId,
|
||||
shopId: nextShopId,
|
||||
binId: nextBinId,
|
||||
price: nextPrice,
|
||||
thc: nextThc,
|
||||
cbd: nextCbd,
|
||||
totalCannabinoids: nextTotalCanna,
|
||||
weight: nextWeight,
|
||||
lastAuditWeight: nextLastAuditWeight,
|
||||
countOriginal: nextCountOriginal,
|
||||
countLastAudit: nextCountLastAudit,
|
||||
unitWeight: nextUnitWeight,
|
||||
purchaseDate: nextPurchaseDate,
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { date, rating, notes } = req.body as {
|
||||
date: string;
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
};
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE inventory_items
|
||||
SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL
|
||||
WHERE id = ? AND status = 'active'`,
|
||||
)
|
||||
.run(date, rating ?? null, notes ?? null, id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { date, reason, notes } = req.body as {
|
||||
date: string;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
};
|
||||
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
|
||||
const tx = db.transaction(() => {
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE inventory_items
|
||||
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL
|
||||
WHERE id = ? AND status = 'active'`,
|
||||
)
|
||||
.run(date, combinedNotes, id);
|
||||
if (result.changes === 0) throw new Error("not found");
|
||||
db.prepare(
|
||||
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||
VALUES (?, ?, 'presence', 0, NULL, 'lost')`,
|
||||
).run(id, date);
|
||||
});
|
||||
try {
|
||||
tx();
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(404).json({ error: "not found or not active" });
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user