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:
+58
-8
@@ -11,18 +11,48 @@ export const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
archiveLegacyIfPresent();
|
||||
|
||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||
db.exec(schema);
|
||||
|
||||
// Add strain_id to products table if missing — older DBs predate the column.
|
||||
const productCols = db
|
||||
.prepare(`PRAGMA table_info(products)`)
|
||||
.all() as { name: string }[];
|
||||
if (!productCols.some((c) => c.name === "strain_id")) {
|
||||
db.exec(`ALTER TABLE products ADD COLUMN strain_id TEXT REFERENCES strains(id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);`);
|
||||
// One-shot migration: the old schema put per-instance fields (weight, bin_id,
|
||||
// etc.) directly on `products`. The new schema splits products (catalog) from
|
||||
// inventory_items (instance). When we detect the old shape, rename the live
|
||||
// tables out of the way so the new CREATE TABLE IF NOT EXISTS statements can
|
||||
// build the empty new ones. Legacy tables stay queryable for reference.
|
||||
function archiveLegacyIfPresent(): void {
|
||||
const productCols = db
|
||||
.prepare(`PRAGMA table_info(products)`)
|
||||
.all() as { name: string }[];
|
||||
const looksLikeOldSchema =
|
||||
productCols.length > 0 && productCols.some((c) => c.name === "weight");
|
||||
if (!looksLikeOldSchema) return;
|
||||
|
||||
const legacyExists = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='products_legacy'`,
|
||||
)
|
||||
.get();
|
||||
if (legacyExists) return;
|
||||
|
||||
db.exec(`
|
||||
ALTER TABLE products RENAME TO products_legacy;
|
||||
ALTER TABLE audits RENAME TO audits_legacy;
|
||||
ALTER TABLE strains RENAME TO strains_legacy;
|
||||
`);
|
||||
}
|
||||
|
||||
const PAD: Record<string, number> = {
|
||||
prd: 4,
|
||||
pdt: 4,
|
||||
inv: 4,
|
||||
str: 4,
|
||||
brd: 2,
|
||||
shp: 2,
|
||||
bin: 2,
|
||||
};
|
||||
|
||||
export function nextId(prefix: string, table: string): string {
|
||||
const row = db
|
||||
.prepare<[string], { id: string }>(
|
||||
@@ -30,11 +60,31 @@ export function nextId(prefix: string, table: string): string {
|
||||
)
|
||||
.get(`${prefix}-%`);
|
||||
const last = row ? Number(row.id.slice(prefix.length + 1)) : 0;
|
||||
const pad = prefix === "prd" || prefix === "str" ? 4 : 2;
|
||||
const pad = PAD[prefix] ?? 2;
|
||||
const n = String(last + 1).padStart(pad, "0");
|
||||
return `${prefix}-${n}`;
|
||||
}
|
||||
|
||||
// Crockford base32 alphabet — drops I, L, O, U so labels are unambiguous when
|
||||
// read off a sticker. 32^6 ≈ 1B addresses, so collisions on any reasonable
|
||||
// inventory are vanishingly rare; we still loop just to be safe.
|
||||
const ASSET_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
export function generateAssetId(): string {
|
||||
for (let attempt = 0; attempt < 16; attempt++) {
|
||||
let id = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
id += ASSET_ALPHABET[Math.floor(Math.random() * ASSET_ALPHABET.length)];
|
||||
}
|
||||
const taken = db
|
||||
.prepare<[string], { id: string }>(
|
||||
`SELECT id FROM inventory_items WHERE asset_id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!taken) return id;
|
||||
}
|
||||
throw new Error("could not generate a unique asset id after 16 attempts");
|
||||
}
|
||||
|
||||
export function randomSku(): string {
|
||||
return "SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import express from "express";
|
||||
import { seedIfEmpty } from "./seed.js";
|
||||
import { bootstrapRouter } from "./routes/bootstrap.js";
|
||||
import { productsRouter } from "./routes/products.js";
|
||||
import { inventoryRouter } from "./routes/inventory.js";
|
||||
import { catalogRouter } from "./routes/catalog.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -18,6 +19,7 @@ app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use("/api", bootstrapRouter);
|
||||
app.use("/api", productsRouter);
|
||||
app.use("/api", inventoryRouter);
|
||||
app.use("/api", catalogRouter);
|
||||
|
||||
app.use(express.static(PUBLIC_DIR));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,13 +35,12 @@ catalogRouter.patch("/brands/:id", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Deleting a brand unparents any products and strains that reference it
|
||||
// (brand_id → NULL), so users never lose products when reorganizing.
|
||||
// Deleting a brand unparents any inventory items that reference it
|
||||
// (brand_id → NULL), so users never lose inventory when reorganizing.
|
||||
catalogRouter.delete("/brands/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id);
|
||||
db.prepare("UPDATE strains SET brand_id = NULL WHERE brand_id = ?").run(id);
|
||||
db.prepare("UPDATE inventory_items SET brand_id = NULL WHERE brand_id = ?").run(id);
|
||||
const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id);
|
||||
if (result.changes === 0) throw new Error("not found");
|
||||
});
|
||||
@@ -87,11 +86,11 @@ catalogRouter.patch("/shops/:id", (req, res) => {
|
||||
res.json({ id, name: nextName, location: nextLocation });
|
||||
});
|
||||
|
||||
// Deleting a shop unparents any products that reference it (shop_id → NULL).
|
||||
// Deleting a shop unparents any inventory items that reference it (shop_id → NULL).
|
||||
catalogRouter.delete("/shops/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare("UPDATE products SET shop_id = NULL WHERE shop_id = ?").run(id);
|
||||
db.prepare("UPDATE inventory_items SET shop_id = NULL WHERE shop_id = ?").run(id);
|
||||
const result = db.prepare("DELETE FROM shops WHERE id = ?").run(id);
|
||||
if (result.changes === 0) throw new Error("not found");
|
||||
});
|
||||
@@ -132,12 +131,12 @@ catalogRouter.patch("/bins/:id", (req, res) => {
|
||||
res.json({ id, name: nextName, capacity: nextCapacity });
|
||||
});
|
||||
|
||||
// Deleting a bin unassigns any products that reference it (bin_id → NULL),
|
||||
// so users never lose products when reorganizing storage.
|
||||
// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL),
|
||||
// so users never lose inventory when reorganizing storage.
|
||||
catalogRouter.delete("/bins/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare("UPDATE products SET bin_id = NULL WHERE bin_id = ?").run(id);
|
||||
db.prepare("UPDATE inventory_items SET bin_id = NULL WHERE bin_id = ?").run(id);
|
||||
const result = db.prepare("DELETE FROM bins WHERE id = ?").run(id);
|
||||
if (result.changes === 0) throw new Error("not found");
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
+142
-320
@@ -1,375 +1,197 @@
|
||||
import { Router } from "express";
|
||||
import { db, nextId, randomSku } from "../db.js";
|
||||
import { db, nextId } from "../db.js";
|
||||
|
||||
export const productsRouter: Router = Router();
|
||||
|
||||
type CreateBody = {
|
||||
sku: string;
|
||||
name: string;
|
||||
brandId: string;
|
||||
shopId: string;
|
||||
binId: string;
|
||||
type: string;
|
||||
kind: "bulk" | "discrete";
|
||||
weight?: number;
|
||||
countOriginal?: number;
|
||||
unitWeight?: number;
|
||||
price: number;
|
||||
thc: number;
|
||||
cbd: number;
|
||||
totalCannabinoids: number;
|
||||
purchaseDate: string;
|
||||
sku?: string;
|
||||
assetTag?: string;
|
||||
strainId?: string | null;
|
||||
strainName?: string;
|
||||
defaultThc?: number;
|
||||
defaultCbd?: number;
|
||||
defaultTotalCannabinoids?: number;
|
||||
};
|
||||
|
||||
productsRouter.post("/products", (req, res) => {
|
||||
const body = req.body as CreateBody;
|
||||
if (!body.name) return res.status(400).json({ error: "name required" });
|
||||
if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" });
|
||||
if (!body.name?.trim()) return res.status(400).json({ error: "name required" });
|
||||
if (!body.type) return res.status(400).json({ error: "type required" });
|
||||
if (body.kind !== "bulk" && body.kind !== "discrete") {
|
||||
return res.status(400).json({ error: "kind must be bulk or discrete" });
|
||||
}
|
||||
|
||||
const id = nextId("prd", "products");
|
||||
const sku = body.sku && body.sku.trim() ? body.sku.trim() : randomSku();
|
||||
const isDiscrete = body.kind === "discrete";
|
||||
const trimmedName = body.name.trim();
|
||||
const brandId = body.brandId ?? null;
|
||||
const sku = body.sku.trim();
|
||||
const name = body.name.trim();
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
// Find-or-create the strain (case-insensitive name match scoped to brand+type).
|
||||
const found = db
|
||||
.prepare<
|
||||
[string, string | null, string | null, string],
|
||||
{ id: string }
|
||||
>(
|
||||
`SELECT id FROM strains
|
||||
WHERE name = ? COLLATE NOCASE
|
||||
AND (brand_id IS ? OR brand_id = ?)
|
||||
AND type = ?`,
|
||||
)
|
||||
.get(trimmedName, brandId, brandId, body.type);
|
||||
const existingSku = db
|
||||
.prepare<[string], { id: string }>("SELECT id FROM products WHERE sku = ?")
|
||||
.get(sku);
|
||||
if (existingSku) {
|
||||
return res.status(409).json({ error: "sku already exists", id: existingSku.id });
|
||||
}
|
||||
|
||||
let strainId: string;
|
||||
if (found) {
|
||||
strainId = found.id;
|
||||
} else {
|
||||
strainId = nextId("str", "strains");
|
||||
db.prepare(`
|
||||
INSERT INTO strains (
|
||||
id, name, brand_id, type,
|
||||
default_thc, default_cbd, default_total_cannabinoids, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
strainId,
|
||||
trimmedName,
|
||||
brandId,
|
||||
body.type,
|
||||
body.thc,
|
||||
body.cbd,
|
||||
body.totalCannabinoids,
|
||||
todayIso,
|
||||
);
|
||||
}
|
||||
const id = nextId("pdt", "products");
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO products (
|
||||
id, sku, asset_tag, name, brand_id, shop_id, bin_id,
|
||||
type, kind, weight, last_audit_weight,
|
||||
count_original, count_last_audit, unit_weight,
|
||||
price, thc, cbd, total_cannabinoids,
|
||||
purchase_date, status, strain_id
|
||||
) VALUES (
|
||||
@id, @sku, @assetTag, @name, @brandId, @shopId, @binId,
|
||||
@type, @kind, @weight, @lastAuditWeight,
|
||||
@countOriginal, @countLastAudit, @unitWeight,
|
||||
@price, @thc, @cbd, @totalCannabinoids,
|
||||
@purchaseDate, 'active', @strainId
|
||||
)
|
||||
`).run({
|
||||
id,
|
||||
sku,
|
||||
assetTag: body.assetTag?.trim() ? body.assetTag.trim() : null,
|
||||
name: trimmedName,
|
||||
brandId,
|
||||
shopId: body.shopId,
|
||||
binId: body.binId,
|
||||
type: body.type,
|
||||
kind: body.kind,
|
||||
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,
|
||||
price: body.price,
|
||||
thc: body.thc,
|
||||
cbd: body.cbd,
|
||||
totalCannabinoids: body.totalCannabinoids,
|
||||
purchaseDate: body.purchaseDate,
|
||||
strainId,
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
let strainId = body.strainId ?? null;
|
||||
const strainName = body.strainName?.trim() || name;
|
||||
|
||||
if (!strainId && strainName) {
|
||||
const found = db
|
||||
.prepare<[string], { id: string }>(
|
||||
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
||||
)
|
||||
.get(strainName);
|
||||
if (found) {
|
||||
strainId = found.id;
|
||||
} else {
|
||||
strainId = nextId("str", "strains");
|
||||
db.prepare(
|
||||
`INSERT INTO strains (
|
||||
id, name,
|
||||
default_thc, default_cbd, default_total_cannabinoids,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
strainId,
|
||||
strainName,
|
||||
body.defaultThc ?? null,
|
||||
body.defaultCbd ?? null,
|
||||
body.defaultTotalCannabinoids ?? null,
|
||||
todayIso,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO products (id, sku, strain_id, name, type, kind, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, sku, strainId, name, body.type, body.kind, todayIso);
|
||||
});
|
||||
});
|
||||
tx();
|
||||
tx();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "";
|
||||
if (msg.includes("UNIQUE")) {
|
||||
return res.status(409).json({ error: "sku already exists" });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
type UpdateBody = Partial<{
|
||||
name: string;
|
||||
brandId: string | null;
|
||||
shopId: string | null;
|
||||
binId: string | null;
|
||||
assetTag: string | null;
|
||||
weight: number;
|
||||
countOriginal: number;
|
||||
unitWeight: number;
|
||||
price: number;
|
||||
thc: number;
|
||||
cbd: number;
|
||||
totalCannabinoids: number;
|
||||
purchaseDate: string;
|
||||
type: string;
|
||||
kind: "bulk" | "discrete";
|
||||
strainId: string | null;
|
||||
}>;
|
||||
|
||||
productsRouter.patch("/products/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const body = req.body as UpdateBody;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand_id: string | null;
|
||||
shop_id: string | null;
|
||||
bin_id: string | null;
|
||||
asset_tag: string | null;
|
||||
type: string;
|
||||
kind: "bulk" | "discrete";
|
||||
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;
|
||||
strain_id: string | null;
|
||||
};
|
||||
|
||||
const existing = db
|
||||
.prepare<[string], Row>(
|
||||
`SELECT id, name, brand_id, shop_id, bin_id, asset_tag, type, kind,
|
||||
weight, last_audit_weight, count_original, count_last_audit, unit_weight,
|
||||
price, thc, cbd, total_cannabinoids, purchase_date, strain_id
|
||||
FROM products WHERE id = ?`,
|
||||
.prepare<
|
||||
[string],
|
||||
{ id: string; name: string; type: string; kind: string; strain_id: string | null }
|
||||
>(
|
||||
`SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!existing) return res.status(404).json({ error: "product not found" });
|
||||
|
||||
const auditCount = db
|
||||
.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM audits WHERE product_id = ?")
|
||||
.get(id)!.n;
|
||||
|
||||
const trimOrUndef = (v: unknown) =>
|
||||
typeof v === "string" ? v.trim() : v;
|
||||
|
||||
const nextName =
|
||||
body.name !== undefined && (body.name as string).trim()
|
||||
? (body.name as string).trim()
|
||||
: existing.name;
|
||||
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 nextAssetTag =
|
||||
body.assetTag === undefined
|
||||
? existing.asset_tag
|
||||
: (trimOrUndef(body.assetTag) as string | null) || 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;
|
||||
typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name;
|
||||
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
||||
const nextKind: "bulk" | "discrete" =
|
||||
body.kind === "bulk" || body.kind === "discrete" ? body.kind : (existing.kind as "bulk" | "discrete");
|
||||
const nextStrainId =
|
||||
body.strainId === undefined ? existing.strain_id : body.strainId;
|
||||
|
||||
const isDiscrete = existing.kind === "discrete";
|
||||
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;
|
||||
|
||||
// If no audits exist yet, keep the "last audit" mirror in lock-step with
|
||||
// the original size — otherwise the next audit's prev_value would be stale.
|
||||
const nextLastAuditWeight =
|
||||
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
|
||||
const nextCountLastAudit =
|
||||
isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
let nextStrainId = existing.strain_id;
|
||||
const nameChanged = nextName !== existing.name;
|
||||
const brandChanged = nextBrandId !== existing.brand_id;
|
||||
if (nameChanged || brandChanged) {
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const found = db
|
||||
.prepare<
|
||||
[string, string | null, string | null, string],
|
||||
{ id: string }
|
||||
>(
|
||||
`SELECT id FROM strains
|
||||
WHERE name = ? COLLATE NOCASE
|
||||
AND (brand_id IS ? OR brand_id = ?)
|
||||
AND type = ?`,
|
||||
)
|
||||
.get(nextName, nextBrandId, nextBrandId, existing.type);
|
||||
if (found) {
|
||||
nextStrainId = found.id;
|
||||
} else {
|
||||
nextStrainId = nextId("str", "strains");
|
||||
db.prepare(`
|
||||
INSERT INTO strains (
|
||||
id, name, brand_id, type,
|
||||
default_thc, default_cbd, default_total_cannabinoids, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nextStrainId,
|
||||
nextName,
|
||||
nextBrandId,
|
||||
existing.type,
|
||||
nextThc,
|
||||
nextCbd,
|
||||
nextTotalCanna,
|
||||
todayIso,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE products SET
|
||||
name = @name,
|
||||
brand_id = @brandId,
|
||||
shop_id = @shopId,
|
||||
bin_id = @binId,
|
||||
asset_tag = @assetTag,
|
||||
weight = @weight,
|
||||
last_audit_weight = @lastAuditWeight,
|
||||
count_original = @countOriginal,
|
||||
count_last_audit = @countLastAudit,
|
||||
unit_weight = @unitWeight,
|
||||
price = @price,
|
||||
thc = @thc,
|
||||
cbd = @cbd,
|
||||
total_cannabinoids = @totalCannabinoids,
|
||||
purchase_date = @purchaseDate,
|
||||
strain_id = @strainId
|
||||
WHERE id = @id
|
||||
`).run({
|
||||
id,
|
||||
name: nextName,
|
||||
brandId: nextBrandId,
|
||||
shopId: nextShopId,
|
||||
binId: nextBinId,
|
||||
assetTag: nextAssetTag,
|
||||
weight: nextWeight,
|
||||
lastAuditWeight: nextLastAuditWeight,
|
||||
countOriginal: nextCountOriginal,
|
||||
countLastAudit: nextCountLastAudit,
|
||||
unitWeight: nextUnitWeight,
|
||||
price: nextPrice,
|
||||
thc: nextThc,
|
||||
cbd: nextCbd,
|
||||
totalCannabinoids: nextTotalCanna,
|
||||
purchaseDate: nextPurchaseDate,
|
||||
strainId: nextStrainId,
|
||||
});
|
||||
});
|
||||
tx();
|
||||
db.prepare(
|
||||
`UPDATE products SET name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`,
|
||||
).run(nextName, nextType, nextKind, nextStrainId, id);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
productsRouter.post("/products/:id/finish", (req, res) => {
|
||||
productsRouter.delete("/products/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string };
|
||||
const result = db
|
||||
.prepare(
|
||||
"UPDATE products SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL WHERE id = ? AND status = 'active'",
|
||||
const inUse = db
|
||||
.prepare<[string], { n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM inventory_items WHERE product_id = ?`,
|
||||
)
|
||||
.run(date, rating ?? null, notes ?? null, id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
||||
.get(id)!.n;
|
||||
if (inUse > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: `product has ${inUse} inventory item${inUse === 1 ? "" : "s"}` });
|
||||
}
|
||||
const result = db.prepare(`DELETE FROM products WHERE id = ?`).run(id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: "product not found" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
productsRouter.post("/products/:id/gone", (req, res) => {
|
||||
// Strain management — kept here since strains are catalog cousins of products.
|
||||
productsRouter.patch("/strains/:id", (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 products 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 (product_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" });
|
||||
}
|
||||
});
|
||||
const body = req.body as Partial<{
|
||||
name: string;
|
||||
defaultThc: number | null;
|
||||
defaultCbd: number | null;
|
||||
defaultTotalCannabinoids: number | null;
|
||||
notes: string | null;
|
||||
}>;
|
||||
|
||||
productsRouter.post("/products/: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 product = db
|
||||
.prepare<[string], { kind: string; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null }>(
|
||||
"SELECT kind, weight, last_audit_weight, count_original, count_last_audit FROM products WHERE id = ?",
|
||||
const existing = db
|
||||
.prepare<
|
||||
[string],
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
default_thc: number | null;
|
||||
default_cbd: number | null;
|
||||
default_total_cannabinoids: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
>(
|
||||
`SELECT id, name, default_thc, default_cbd, default_total_cannabinoids, notes
|
||||
FROM strains WHERE id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!product) return res.status(404).json({ error: "not found" });
|
||||
if (!existing) return res.status(404).json({ error: "strain not found" });
|
||||
|
||||
const prev =
|
||||
product.kind === "discrete"
|
||||
? product.count_last_audit ?? product.count_original
|
||||
: product.last_audit_weight ?? product.weight;
|
||||
const nextName =
|
||||
typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name;
|
||||
const nextThc =
|
||||
body.defaultThc === undefined ? existing.default_thc : body.defaultThc;
|
||||
const nextCbd =
|
||||
body.defaultCbd === undefined ? existing.default_cbd : body.defaultCbd;
|
||||
const nextTotal =
|
||||
body.defaultTotalCannabinoids === undefined
|
||||
? existing.default_total_cannabinoids
|
||||
: body.defaultTotalCannabinoids;
|
||||
const nextNotes = body.notes === undefined ? existing.notes : body.notes;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT INTO audits (product_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(id, date, mode, value, prev, confirmedBy ?? null);
|
||||
if (product.kind === "discrete") {
|
||||
db.prepare("UPDATE products SET count_last_audit = ? WHERE id = ?").run(value, id);
|
||||
} else {
|
||||
db.prepare("UPDATE products SET last_audit_weight = ? WHERE id = ?").run(value, id);
|
||||
`UPDATE strains SET
|
||||
name = ?, default_thc = ?, default_cbd = ?, default_total_cannabinoids = ?, notes = ?
|
||||
WHERE id = ?`,
|
||||
).run(nextName, nextThc, nextCbd, nextTotal, nextNotes, id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "";
|
||||
if (msg.includes("UNIQUE")) {
|
||||
return res.status(409).json({ error: "another strain already uses that name" });
|
||||
}
|
||||
});
|
||||
tx();
|
||||
res.json({ ok: true });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
+58
-41
@@ -1,3 +1,5 @@
|
||||
-- Catalog tables (brands, shops, bins) — pure name/metadata records.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shops (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -16,57 +18,72 @@ CREATE TABLE IF NOT EXISTS bins (
|
||||
capacity INTEGER NOT NULL DEFAULT 10
|
||||
);
|
||||
|
||||
-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only,
|
||||
-- since brand and physical form (type) are now per-product / per-instance.
|
||||
CREATE TABLE IF NOT EXISTS strains (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
brand_id TEXT REFERENCES brands(id),
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
default_thc REAL,
|
||||
default_cbd REAL,
|
||||
default_total_cannabinoids REAL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(name, brand_id, type)
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strains_brand_type ON strains(brand_id, type);
|
||||
|
||||
-- Products: catalog entries. One row per (sku, strain) combination —
|
||||
-- a "kind of thing you can scan". Type/kind describe the physical form
|
||||
-- (e.g. Flower/bulk, Pre-roll/discrete) since the same strain can ship in
|
||||
-- different forms with different SKUs.
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
sku TEXT NOT NULL,
|
||||
asset_tag TEXT,
|
||||
name TEXT NOT NULL,
|
||||
brand_id TEXT REFERENCES brands(id),
|
||||
shop_id TEXT REFERENCES shops(id),
|
||||
bin_id TEXT REFERENCES bins(id),
|
||||
type TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
weight REAL DEFAULT 0,
|
||||
last_audit_weight REAL,
|
||||
count_original INTEGER DEFAULT 0,
|
||||
count_last_audit INTEGER,
|
||||
unit_weight REAL DEFAULT 0,
|
||||
price REAL NOT NULL,
|
||||
thc REAL DEFAULT 0,
|
||||
cbd REAL DEFAULT 0,
|
||||
total_cannabinoids REAL DEFAULT 0,
|
||||
purchase_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
consumed_date TEXT,
|
||||
gone_date TEXT,
|
||||
rating INTEGER,
|
||||
notes TEXT
|
||||
id TEXT PRIMARY KEY,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
strain_id TEXT REFERENCES strains(id),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);
|
||||
|
||||
-- Inventory items: physical things you bought. One row per acquired
|
||||
-- jar/pack/cart, with its own short-UUID asset id (label-printed).
|
||||
-- Per-batch values (price, THC, weight or count, brand, shop, bin)
|
||||
-- live here, since they vary between purchases of the same product.
|
||||
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
asset_id TEXT NOT NULL UNIQUE,
|
||||
product_id TEXT NOT NULL REFERENCES products(id),
|
||||
brand_id TEXT REFERENCES brands(id),
|
||||
shop_id TEXT REFERENCES shops(id),
|
||||
bin_id TEXT REFERENCES bins(id),
|
||||
price REAL NOT NULL,
|
||||
thc REAL DEFAULT 0,
|
||||
cbd REAL DEFAULT 0,
|
||||
total_cannabinoids REAL DEFAULT 0,
|
||||
weight REAL DEFAULT 0,
|
||||
last_audit_weight REAL,
|
||||
count_original INTEGER DEFAULT 0,
|
||||
count_last_audit INTEGER,
|
||||
unit_weight REAL DEFAULT 0,
|
||||
purchase_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
consumed_date TEXT,
|
||||
gone_date TEXT,
|
||||
rating INTEGER,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inv_product ON inventory_items(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_inv_status ON inventory_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_inv_bin ON inventory_items(bin_id);
|
||||
|
||||
-- Audits: append-only history, keyed on inventory item.
|
||||
CREATE TABLE IF NOT EXISTS audits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
prev_value REAL,
|
||||
confirmed_by TEXT
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inventory_id TEXT NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
prev_value REAL,
|
||||
confirmed_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_audits_product ON audits(product_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_audits_inventory ON audits(inventory_id, date);
|
||||
|
||||
Reference in New Issue
Block a user