Track inventory at the instance level, not by product
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:
2026-05-04 05:59:46 -04:00
parent 1abfda7989
commit 02dc6e523f
28 changed files with 2315 additions and 1355 deletions
+313
View File
@@ -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 });
});