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:
+35
-31
@@ -1,10 +1,10 @@
|
||||
// computeStats — ported from primitives.jsx:41-235
|
||||
// Derives daily/weekly/monthly grams from purchase + audit history,
|
||||
// using estimated remaining for active items and full weight for consumed.
|
||||
// Gone items contribute spend but NOT grams (so daily averages stay clean).
|
||||
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
||||
// history, using estimated remaining for active items and full weight for
|
||||
// consumed. Gone items contribute spend but NOT grams (so daily averages
|
||||
// stay clean). Operates on the enriched Item[] view, not raw products.
|
||||
|
||||
import type { Bootstrap, Product } from "./types.js";
|
||||
import { TYPES, TODAY_STR, helpers } from "./types.js";
|
||||
import type { Bootstrap, Item } from "./types.js";
|
||||
import { TYPES, TODAY_STR, helpers, enrichItems } from "./types.js";
|
||||
|
||||
export interface Stats {
|
||||
dailyAvg: number;
|
||||
@@ -34,14 +34,15 @@ export interface Stats {
|
||||
consumedCount: number;
|
||||
goneCount: number;
|
||||
archivedCount: number;
|
||||
overdueAudits: Product[];
|
||||
lowStockBulk: Product[];
|
||||
purchaseCount: number;
|
||||
overdueAudits: Item[];
|
||||
lowStockBulk: Item[];
|
||||
lowStockDiscreteGroups: {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
brandId: string | null;
|
||||
items: Product[];
|
||||
items: Item[];
|
||||
totalCount: number;
|
||||
}[];
|
||||
}
|
||||
@@ -49,33 +50,33 @@ export interface Stats {
|
||||
export function computeStats(data: Bootstrap): Stats {
|
||||
const today = new Date(data.today || TODAY_STR);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const products = data.products;
|
||||
const items = enrichItems(data);
|
||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
const active = products.filter((p) => p.status === "active");
|
||||
const consumed = products.filter((p) => p.status === "consumed" && p.consumedDate);
|
||||
const gone = products.filter((p) => p.status === "gone");
|
||||
const active = items.filter((p) => p.status === "active");
|
||||
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
|
||||
const gone = items.filter((p) => p.status === "gone");
|
||||
|
||||
const purchasesIn = (days: number) => {
|
||||
const cutoff = new Date(today);
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return products.filter((p) => new Date(p.purchaseDate) >= cutoff);
|
||||
return items.filter((p) => new Date(p.purchaseDate) >= cutoff);
|
||||
};
|
||||
const last7p = purchasesIn(7);
|
||||
const last30p = purchasesIn(30);
|
||||
const last90p = purchasesIn(90);
|
||||
|
||||
const bulkGrams = (p: Product): number => {
|
||||
const bulkGrams = (p: Item): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsConsumed = (p: Product): number => {
|
||||
const bulkGramsConsumed = (p: Item): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsUsedSoFar = (p: Product): number => {
|
||||
const bulkGramsUsedSoFar = (p: Item): number => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") {
|
||||
const est = helpers.estimatedRemaining(p, todayStr);
|
||||
@@ -133,9 +134,9 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
const weeklyAvg = sumG(series30) / (30 / 7);
|
||||
const monthlyAvg = sumG(series90) / 3;
|
||||
|
||||
const totalSpend = products.reduce((s, p) => s + p.price, 0);
|
||||
const totalSpend = items.reduce((s, p) => s + p.price, 0);
|
||||
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
|
||||
const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0);
|
||||
const totalGrams = items.reduce((s, p) => s + bulkGrams(p), 0);
|
||||
const avgPerGram = totalGrams ? totalSpend / totalGrams : 0;
|
||||
const spend30 = last30p.reduce((s, p) => s + p.price, 0);
|
||||
const spend7 = last7p.reduce((s, p) => s + p.price, 0);
|
||||
@@ -157,7 +158,7 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
}, 0);
|
||||
|
||||
const avgThc =
|
||||
products.length > 0 ? products.reduce((s, p) => s + p.thc, 0) / products.length : 20;
|
||||
items.length > 0 ? items.reduce((s, p) => s + p.thc, 0) / items.length : 20;
|
||||
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
|
||||
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
|
||||
|
||||
@@ -172,7 +173,7 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
|
||||
const shopCount: Record<string, number> = {};
|
||||
const brandCount: Record<string, number> = {};
|
||||
products.forEach((p) => {
|
||||
items.forEach((p) => {
|
||||
if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1;
|
||||
if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1;
|
||||
});
|
||||
@@ -204,7 +205,7 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
}, 0);
|
||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
||||
|
||||
const sortedDates = [...products]
|
||||
const sortedDates = [...items]
|
||||
.sort((a, b) => +new Date(a.purchaseDate) - +new Date(b.purchaseDate))
|
||||
.map((p) => new Date(p.purchaseDate));
|
||||
const gaps: number[] = [];
|
||||
@@ -219,16 +220,18 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
(p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25,
|
||||
);
|
||||
|
||||
const discreteBrandGroups: Record<
|
||||
// Group discrete instances by product so multiple jars of the same
|
||||
// pre-roll/edible product collapse into a single "running low" row.
|
||||
const discreteGroups: Record<
|
||||
string,
|
||||
{ key: string; name: string; type: string; brandId: string | null; items: Product[]; totalCount: number }
|
||||
{ key: string; name: string; type: string; brandId: string | null; items: Item[]; totalCount: number }
|
||||
> = {};
|
||||
active
|
||||
.filter((p) => p.kind === "discrete")
|
||||
.forEach((p) => {
|
||||
const k = `${p.brandId}|${p.type}|${p.name}`;
|
||||
if (!discreteBrandGroups[k]) {
|
||||
discreteBrandGroups[k] = {
|
||||
const k = p.productId;
|
||||
if (!discreteGroups[k]) {
|
||||
discreteGroups[k] = {
|
||||
key: k,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
@@ -237,10 +240,10 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
discreteBrandGroups[k].items.push(p);
|
||||
discreteBrandGroups[k].totalCount += p.countLastAudit ?? p.countOriginal;
|
||||
discreteGroups[k].items.push(p);
|
||||
discreteGroups[k].totalCount += p.countLastAudit ?? p.countOriginal;
|
||||
});
|
||||
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(
|
||||
const lowStockDiscreteGroups = Object.values(discreteGroups).filter(
|
||||
(g) => g.totalCount <= 2,
|
||||
);
|
||||
|
||||
@@ -272,6 +275,7 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
consumedCount: consumed.length,
|
||||
goneCount: gone.length,
|
||||
archivedCount: consumed.length + gone.length,
|
||||
purchaseCount: items.length,
|
||||
overdueAudits,
|
||||
lowStockBulk,
|
||||
lowStockDiscreteGroups,
|
||||
@@ -279,7 +283,7 @@ export function computeStats(data: Bootstrap): Stats {
|
||||
}
|
||||
|
||||
// Display helpers used throughout the UI
|
||||
export function remainingShort(p: Product): string {
|
||||
export function remainingShort(p: Item): string {
|
||||
const cfg = TYPES.find((t) => t.id === p.type);
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit ?? p.countOriginal;
|
||||
|
||||
Reference in New Issue
Block a user