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
+53 -39
View File
@@ -6,36 +6,40 @@ export const bootstrapRouter: Router = Router();
type ProductRow = {
id: string;
sku: string;
asset_tag: string | null;
strain_id: string | null;
name: string;
type: string;
kind: string;
created_at: string;
};
type InventoryRow = {
id: string;
asset_id: string;
product_id: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
type: string;
kind: 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;
price: number;
thc: number;
cbd: number;
total_cannabinoids: number;
purchase_date: string;
status: string;
consumed_date: string | null;
gone_date: string | null;
rating: number | null;
notes: string | null;
strain_id: string | null;
};
type StrainRow = {
id: string;
name: string;
brand_id: string | null;
type: string;
default_thc: number | null;
default_cbd: number | null;
default_total_cannabinoids: number | null;
@@ -44,7 +48,7 @@ type StrainRow = {
type AuditRow = {
id: number;
product_id: string;
inventory_id: string;
date: string;
mode: string;
value: number;
@@ -53,9 +57,14 @@ type AuditRow = {
};
bootstrapRouter.get("/bootstrap", (_req, res) => {
const products = db.prepare<[], ProductRow>("SELECT * FROM products ORDER BY id").all();
const products = db
.prepare<[], ProductRow>("SELECT * FROM products ORDER BY id")
.all();
const inventory = db
.prepare<[], InventoryRow>("SELECT * FROM inventory_items ORDER BY id")
.all();
const audits = db
.prepare<[], AuditRow>("SELECT * FROM audits ORDER BY product_id, date")
.prepare<[], AuditRow>("SELECT * FROM audits ORDER BY inventory_id, date")
.all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
@@ -64,40 +73,46 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all();
const auditsByProduct = new Map<string, AuditRow[]>();
const auditsByInventory = new Map<string, AuditRow[]>();
for (const a of audits) {
const arr = auditsByProduct.get(a.product_id) ?? [];
const arr = auditsByInventory.get(a.inventory_id) ?? [];
arr.push(a);
auditsByProduct.set(a.product_id, arr);
auditsByInventory.set(a.inventory_id, arr);
}
const productsOut = products.map((p) => ({
id: p.id,
sku: p.sku,
assetTag: p.asset_tag,
strainId: p.strain_id,
name: p.name,
brandId: p.brand_id,
shopId: p.shop_id,
binId: p.bin_id,
type: p.type,
kind: p.kind,
weight: p.weight,
lastAuditWeight: p.last_audit_weight,
countOriginal: p.count_original,
countLastAudit: p.count_last_audit,
unitWeight: p.unit_weight,
price: p.price,
thc: p.thc,
cbd: p.cbd,
totalCannabinoids: p.total_cannabinoids,
purchaseDate: p.purchase_date,
status: p.status,
consumedDate: p.consumed_date,
goneDate: p.gone_date,
rating: p.rating,
notes: p.notes,
strainId: p.strain_id,
audits: (auditsByProduct.get(p.id) ?? []).map((a) => ({
createdAt: p.created_at,
}));
const inventoryOut = inventory.map((i) => ({
id: i.id,
assetId: i.asset_id,
productId: i.product_id,
brandId: i.brand_id,
shopId: i.shop_id,
binId: i.bin_id,
price: i.price,
thc: i.thc,
cbd: i.cbd,
totalCannabinoids: i.total_cannabinoids,
weight: i.weight,
lastAuditWeight: i.last_audit_weight,
countOriginal: i.count_original,
countLastAudit: i.count_last_audit,
unitWeight: i.unit_weight,
purchaseDate: i.purchase_date,
status: i.status,
consumedDate: i.consumed_date,
goneDate: i.gone_date,
rating: i.rating,
notes: i.notes,
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
date: a.date,
mode: a.mode,
value: a.value,
@@ -109,8 +124,6 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
const strainsOut = strains.map((s) => ({
id: s.id,
name: s.name,
brandId: s.brand_id,
type: s.type,
defaultThc: s.default_thc,
defaultCbd: s.default_cbd,
defaultTotalCannabinoids: s.default_total_cannabinoids,
@@ -119,6 +132,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
res.json({
products: productsOut,
inventoryItems: inventoryOut,
shops,
brands,
bins,