5fa1e34914
Build and push image / build (push) Successful in 57s
Replaces the unified audit system with two purpose-built flows: - Weigh Ins: rebranded audit flow for bulk products (Flower, Concentrate, Tincture) with scale weigh, container weigh, and estimate modes - Bin Checks: new bin-level presence check — select a bin, scan every item, resolve discrepancies (wrong bin, unknown, missing), auto-records presence audits on verified items Adds cadence_days and last_checked to bins table, with per-bin overdue tracking on the dashboard and bins view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
|
// history, using last audit values 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 { Bin, Bootstrap, Item } from "./types.js";
|
|
import { TYPES, helpers, enrichItems } from "./types.js";
|
|
import { getToday, getStoredTimezone } from "./tz.js";
|
|
|
|
export interface Stats {
|
|
dailyAvg: number;
|
|
weeklyAvg: number;
|
|
monthlyAvg: number;
|
|
totalSpend: number;
|
|
avgPerGram: number;
|
|
spend7: number;
|
|
spend30: number;
|
|
spend90: number;
|
|
goneSpend: number;
|
|
inventoryValue: number;
|
|
inventoryGrams: number;
|
|
totalGrams: number;
|
|
thcLast7: number;
|
|
thcLast30: number;
|
|
avgLifespan: number;
|
|
favShop: [string, number];
|
|
favBrand: [string, number];
|
|
typeBreakdown: Record<string, number>;
|
|
daysOfSupply: number;
|
|
avgGap: number;
|
|
series7: { date: string; grams: number }[];
|
|
series30: { date: string; grams: number }[];
|
|
series90: { date: string; grams: number }[];
|
|
activeCount: number;
|
|
checkedOutCount: number;
|
|
consumedCount: number;
|
|
goneCount: number;
|
|
archivedCount: number;
|
|
purchaseCount: number;
|
|
overdueWeighIns: Item[];
|
|
overdueBinChecks: Bin[];
|
|
lowStockBulk: Item[];
|
|
lowStockDiscreteGroups: {
|
|
key: string;
|
|
name: string;
|
|
type: string;
|
|
brandId: string | null;
|
|
items: Item[];
|
|
totalCount: number;
|
|
}[];
|
|
}
|
|
|
|
export function computeStats(data: Bootstrap): Stats {
|
|
const tz = getStoredTimezone();
|
|
const todayStr = getToday(tz);
|
|
const today = new Date(todayStr + "T12:00:00");
|
|
const items = enrichItems(data);
|
|
const dayKeyFmt = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: tz,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
const dayKey = (d: Date) => dayKeyFmt.format(d);
|
|
|
|
const active = items.filter((p) => p.status === "active" || p.status === "checked-out");
|
|
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 items.filter((p) => new Date(p.purchaseDate) >= cutoff);
|
|
};
|
|
const last7p = purchasesIn(7);
|
|
const last30p = purchasesIn(30);
|
|
const last90p = purchasesIn(90);
|
|
|
|
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: 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: Item): number => {
|
|
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
|
if (p.kind === "bulk") {
|
|
const rem = helpers.remaining(p);
|
|
return Math.max(0, p.weight - rem);
|
|
}
|
|
const cur = p.countLastAudit ?? p.countOriginal;
|
|
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
|
};
|
|
|
|
const dailyGramsAttribution: Record<string, number> = {};
|
|
consumed.forEach((p) => {
|
|
const g = bulkGramsConsumed(p);
|
|
if (g <= 0 || !p.consumedDate) return;
|
|
const start = new Date(p.purchaseDate);
|
|
const end = new Date(p.consumedDate);
|
|
const days = Math.max(1, Math.round((+end - +start) / 86_400_000));
|
|
const perDay = g / days;
|
|
for (let i = 0; i < days; i++) {
|
|
const d = new Date(start);
|
|
d.setDate(d.getDate() + i);
|
|
const k = dayKey(d);
|
|
dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay;
|
|
}
|
|
});
|
|
active.forEach((p) => {
|
|
const used = bulkGramsUsedSoFar(p);
|
|
if (used <= 0) return;
|
|
const start = new Date(p.purchaseDate);
|
|
const days = Math.max(1, Math.round((+today - +start) / 86_400_000));
|
|
const perDay = used / days;
|
|
for (let i = 0; i < days; i++) {
|
|
const d = new Date(start);
|
|
d.setDate(d.getDate() + i);
|
|
const k = dayKey(d);
|
|
dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay;
|
|
}
|
|
});
|
|
|
|
const seriesFor = (days: number) => {
|
|
const out: { date: string; grams: number }[] = [];
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const k = dayKey(d);
|
|
out.push({ date: k, grams: dailyGramsAttribution[k] || 0 });
|
|
}
|
|
return out;
|
|
};
|
|
const series7 = seriesFor(7);
|
|
const series30 = seriesFor(30);
|
|
const series90 = seriesFor(90);
|
|
|
|
const sumG = (xs: { grams: number }[]) => xs.reduce((s, x) => s + x.grams, 0);
|
|
const dailyAvg = sumG(series30) / 30;
|
|
const weeklyAvg = sumG(series30) / (30 / 7);
|
|
const monthlyAvg = sumG(series90) / 3;
|
|
|
|
const totalSpend = items.reduce((s, p) => s + p.price, 0);
|
|
const goneSpend = gone.reduce((s, p) => s + p.price, 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);
|
|
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
|
|
|
const inventoryValue = active.reduce(
|
|
(s, p) => s + p.price * helpers.pctRemaining(p),
|
|
0,
|
|
);
|
|
|
|
const inventoryGrams = active.reduce((s, p) => {
|
|
if (p.type === "Tincture" || p.type === "Edible") return s;
|
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
|
const cur = p.countLastAudit ?? p.countOriginal;
|
|
return s + cur * (p.unitWeight || 0);
|
|
}, 0);
|
|
|
|
const avgThc =
|
|
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);
|
|
|
|
const lifespans = consumed.map((p) =>
|
|
Math.max(
|
|
1,
|
|
Math.round((+new Date(p.consumedDate!) - +new Date(p.purchaseDate)) / 86_400_000),
|
|
),
|
|
);
|
|
const avgLifespan =
|
|
lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : 0;
|
|
|
|
const shopCount: Record<string, number> = {};
|
|
const brandCount: Record<string, number> = {};
|
|
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;
|
|
});
|
|
const topShopEntry = Object.entries(shopCount).sort((a, b) => b[1] - a[1])[0];
|
|
const topBrandEntry = Object.entries(brandCount).sort((a, b) => b[1] - a[1])[0];
|
|
const favShop: [string, number] = topShopEntry
|
|
? [helpers.shopName(data, topShopEntry[0]), topShopEntry[1]]
|
|
: ["—", 0];
|
|
const favBrand: [string, number] = topBrandEntry
|
|
? [helpers.brandName(data, topBrandEntry[0]), topBrandEntry[1]]
|
|
: ["—", 0];
|
|
|
|
const typeBreakdown: Record<string, number> = {};
|
|
active.forEach((p) => {
|
|
let g: number;
|
|
if (p.type === "Tincture") g = helpers.remaining(p) * 0.5;
|
|
else if (p.type === "Edible")
|
|
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
|
else if (p.kind === "bulk") g = helpers.remaining(p);
|
|
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
|
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
|
});
|
|
|
|
const flowerEquivalent = active
|
|
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
|
.reduce((s, p) => {
|
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
|
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
|
}, 0);
|
|
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
|
|
|
const sortedDates = [...items]
|
|
.sort((a, b) => +new Date(a.purchaseDate) - +new Date(b.purchaseDate))
|
|
.map((p) => new Date(p.purchaseDate));
|
|
const gaps: number[] = [];
|
|
for (let i = 1; i < sortedDates.length; i++) {
|
|
gaps.push((+sortedDates[i]! - +sortedDates[i - 1]!) / 86_400_000);
|
|
}
|
|
const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
|
|
|
|
const overdueWeighIns = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
|
const overdueBinChecks = data.bins.filter((b) => helpers.binCheckOverdue(b, todayStr));
|
|
|
|
const lowStockBulk = active.filter(
|
|
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25,
|
|
);
|
|
|
|
// 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: Item[]; totalCount: number }
|
|
> = {};
|
|
active
|
|
.filter((p) => p.kind === "discrete")
|
|
.forEach((p) => {
|
|
const k = p.productId;
|
|
if (!discreteGroups[k]) {
|
|
discreteGroups[k] = {
|
|
key: k,
|
|
name: p.name,
|
|
type: p.type,
|
|
brandId: p.brandId,
|
|
items: [],
|
|
totalCount: 0,
|
|
};
|
|
}
|
|
discreteGroups[k].items.push(p);
|
|
discreteGroups[k].totalCount += p.countLastAudit ?? p.countOriginal;
|
|
});
|
|
const lowStockDiscreteGroups = Object.values(discreteGroups).filter(
|
|
(g) => g.totalCount <= 2,
|
|
);
|
|
|
|
return {
|
|
dailyAvg,
|
|
weeklyAvg,
|
|
monthlyAvg,
|
|
totalSpend,
|
|
avgPerGram,
|
|
spend7,
|
|
spend30,
|
|
spend90,
|
|
goneSpend,
|
|
inventoryValue,
|
|
inventoryGrams,
|
|
totalGrams,
|
|
thcLast7,
|
|
thcLast30,
|
|
avgLifespan,
|
|
favShop,
|
|
favBrand,
|
|
typeBreakdown,
|
|
daysOfSupply,
|
|
avgGap,
|
|
series7,
|
|
series30,
|
|
series90,
|
|
activeCount: items.filter((p) => p.status === "active").length,
|
|
checkedOutCount: items.filter((p) => p.status === "checked-out").length,
|
|
consumedCount: consumed.length,
|
|
goneCount: gone.length,
|
|
archivedCount: consumed.length + gone.length,
|
|
purchaseCount: items.length,
|
|
overdueWeighIns,
|
|
overdueBinChecks,
|
|
lowStockBulk,
|
|
lowStockDiscreteGroups,
|
|
};
|
|
}
|
|
|
|
// Display helpers used throughout the UI
|
|
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;
|
|
return `${cur} ct`;
|
|
}
|
|
const rem = helpers.remaining(p);
|
|
const trimmed = rem.toFixed(2).replace(/\.?0+$/, "") || "0";
|
|
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
|
}
|