Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
Build and push image / build (push) Successful in 57s
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user