Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
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>
This commit is contained in:
2026-06-06 18:28:55 -04:00
parent d7da7afe5e
commit 5fa1e34914
19 changed files with 769 additions and 134 deletions
+10
View File
@@ -16,6 +16,7 @@ archiveV1IfPresent();
migrateAddCheckoutDate();
migrateAddContainerWeight();
migrateAddPrevBinId();
migrateAddBinCheckFields();
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema);
@@ -44,6 +45,15 @@ function migrateAddPrevBinId(): void {
db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`);
}
function migrateAddBinCheckFields(): void {
const cols = db
.prepare(`PRAGMA table_info(bins)`)
.all() as { name: string }[];
if (cols.length === 0 || cols.some((c) => c.name === "cadence_days")) return;
db.exec(`ALTER TABLE bins ADD COLUMN cadence_days INTEGER NOT NULL DEFAULT 30`);
db.exec(`ALTER TABLE bins ADD COLUMN last_checked TEXT`);
}
// One-shot migration: the original schema put per-instance fields (weight,
// bin_id, etc.) directly on `products`. The split schema separates products
// (catalog) from inventory_items (instance). When we detect the old shape,
+8 -1
View File
@@ -70,7 +70,14 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
.all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
const bins = db.prepare("SELECT id, name, capacity FROM bins ORDER BY id").all();
const binsRaw = db.prepare("SELECT id, name, capacity, cadence_days, last_checked FROM bins ORDER BY id").all() as { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }[];
const bins = binsRaw.map((b) => ({
id: b.id,
name: b.name,
capacity: b.capacity,
cadenceDays: b.cadence_days,
lastChecked: b.last_checked,
}));
const strains = db
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all();
+13 -8
View File
@@ -103,20 +103,21 @@ catalogRouter.delete("/shops/:id", (req, res) => {
});
catalogRouter.post("/bins", (req, res) => {
const { name, capacity } = req.body as { name: string; capacity?: number };
const { name, capacity, cadenceDays } = req.body as { name: string; capacity?: number; cadenceDays?: number };
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("bin", "bins");
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap);
res.json({ id, name: name.trim(), capacity: cap });
const cad = Number.isFinite(cadenceDays) && (cadenceDays as number) > 0 ? Math.floor(cadenceDays as number) : 30;
db.prepare("INSERT INTO bins (id, name, capacity, cadence_days) VALUES (?, ?, ?, ?)").run(id, name.trim(), cap, cad);
res.json({ id, name: name.trim(), capacity: cap, cadenceDays: cad, lastChecked: null });
});
catalogRouter.patch("/bins/:id", (req, res) => {
const { id } = req.params;
const { name, capacity } = req.body as { name?: string; capacity?: number };
const { name, capacity, cadenceDays } = req.body as { name?: string; capacity?: number; cadenceDays?: number };
const existing = db
.prepare<[string], { id: string; name: string; capacity: number }>(
"SELECT id, name, capacity FROM bins WHERE id = ?",
.prepare<[string], { id: string; name: string; capacity: number; cadence_days: number; last_checked: string | null }>(
"SELECT id, name, capacity, cadence_days, last_checked FROM bins WHERE id = ?",
)
.get(id);
if (!existing) return res.status(404).json({ error: "bin not found" });
@@ -126,9 +127,13 @@ catalogRouter.patch("/bins/:id", (req, res) => {
Number.isFinite(capacity) && (capacity as number) > 0
? Math.floor(capacity as number)
: existing.capacity;
const nextCadence =
Number.isFinite(cadenceDays) && (cadenceDays as number) > 0
? Math.floor(cadenceDays as number)
: existing.cadence_days;
db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id);
res.json({ id, name: nextName, capacity: nextCapacity });
db.prepare("UPDATE bins SET name = ?, capacity = ?, cadence_days = ? WHERE id = ?").run(nextName, nextCapacity, nextCadence, id);
res.json({ id, name: nextName, capacity: nextCapacity, cadenceDays: nextCadence, lastChecked: existing.last_checked });
});
// Deleting a bin unassigns any inventory items that reference it (bin_id → NULL),
+56
View File
@@ -435,6 +435,62 @@ inventoryRouter.post("/inventory/:id/audit", (req, res) => {
res.json({ ok: true });
});
// ─── Bin check endpoint ──────────────────────────────────────────
inventoryRouter.post("/bins/:id/check", (req, res) => {
const { id } = req.params;
const { date, verifiedItemIds, goneItemIds } = req.body as {
date: string;
verifiedItemIds: string[];
goneItemIds: string[];
};
const bin = db
.prepare<[string], { id: string }>("SELECT id FROM bins WHERE id = ?")
.get(id);
if (!bin) return res.status(404).json({ error: "bin not found" });
const tx = db.transaction(() => {
for (const itemId of verifiedItemIds) {
const item = db
.prepare<
[string],
{ product_id: string; count_original: number; count_last_audit: number | null }
>(
`SELECT product_id, count_original, count_last_audit FROM inventory_items WHERE id = ?`,
)
.get(itemId);
if (!item) continue;
const prev = item.count_last_audit ?? item.count_original;
db.prepare(
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
VALUES (?, ?, 'presence', ?, ?, 'bin-check')`,
).run(itemId, date, prev, prev);
db.prepare(
`UPDATE inventory_items SET count_last_audit = ? WHERE id = ?`,
).run(prev, itemId);
}
for (const itemId of goneItemIds) {
try {
doGone(itemId, date, "missing from bin check");
} catch {
// item may already be gone
}
}
db.prepare("UPDATE bins SET last_checked = ? WHERE id = ?").run(date, id);
});
try {
tx();
res.json({ ok: true, verified: verifiedItemIds.length, gone: goneItemIds.length });
} catch (e: any) {
res.status(400).json({ error: e.message });
}
});
// ─── Batch endpoint ───────────────────────────────────────────────
type BatchOp =
+3 -1
View File
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS bins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
capacity INTEGER NOT NULL DEFAULT 10
capacity INTEGER NOT NULL DEFAULT 10,
cadence_days INTEGER NOT NULL DEFAULT 30,
last_checked TEXT
);
-- Strains: one row per cannabis strain (catalog-level). UNIQUE on name only,