diff --git a/main.ts b/main.ts index c07ced1..d83501d 100644 --- a/main.ts +++ b/main.ts @@ -2,6 +2,8 @@ import { Hono } from "hono"; import { serveStatic } from "hono/deno"; import { sql, initDb } from "./src/db/connection.ts"; import { qaRoutes } from "./src/routes/qa.ts"; +import { bomRoutes } from "./src/routes/bom.ts"; +import { printFarmRoutes } from "./src/routes/printfarm.ts"; const app = new Hono(); @@ -25,6 +27,8 @@ app.get("/api/status", async (c) => { // Mount modular sub-routes app.route("/api/qa", qaRoutes); +app.route("/api/bom", bomRoutes); +app.route("/api/print-farm", printFarmRoutes); // Serve the 3D Viewer frontend app.use("/*", serveStatic({ root: "./" })); diff --git a/src/db/bom.ts b/src/db/bom.ts new file mode 100644 index 0000000..126924e --- /dev/null +++ b/src/db/bom.ts @@ -0,0 +1,50 @@ +import { sql } from "./connection.ts"; + +export interface BomItem { + id: string; + component_name: string; + mpn: string; + vendor: string; + unit_price: number; + quantity: number; + lead_time_days: number; + status: "in_stock" | "ordered" | "backordered"; + category: "compute" | "storage" | "power" | "mechanical" | "cables" | "fasteners"; + notes?: string; + updated_at?: string; +} + +export const getBomItems = async (category?: string): Promise => { + if (category) { + return await sql` + SELECT * FROM bom_items WHERE category = ${category} ORDER BY category, component_name ASC + `; + } + return await sql` + SELECT * FROM bom_items ORDER BY category, component_name ASC + `; +}; + +export const getBomSummary = async () => { + const result = await sql` + SELECT + COUNT(*) as total_parts, + SUM(quantity) as total_units, + SUM(unit_price * quantity) as total_cost, + MAX(lead_time_days) as max_lead_time, + COUNT(CASE WHEN status = 'in_stock' THEN 1 END) as in_stock_count, + COUNT(CASE WHEN status = 'ordered' THEN 1 END) as ordered_count, + COUNT(CASE WHEN status = 'backordered' THEN 1 END) as backordered_count + FROM bom_items + `; + return result[0]; +}; + +export const updateBomItemStatus = async (id: string, status: string): Promise => { + return await sql` + UPDATE bom_items + SET status = ${status}, updated_at = CURRENT_TIMESTAMP + WHERE id = ${id} + RETURNING * + `; +}; diff --git a/src/db/connection.ts b/src/db/connection.ts index 7d0a16d..6660475 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -14,6 +14,7 @@ export const sql = postgres({ export async function initDb(retries = 5, delayMs = 2000) { for (let i = 0; i < retries; i++) { try { + // 1. QA Checklists await sql` CREATE TABLE IF NOT EXISTS qa_checklists ( id VARCHAR(50) PRIMARY KEY, @@ -23,17 +24,87 @@ export async function initDb(retries = 5, delayMs = 2000) { ); `; - const count = await sql`SELECT count(*) FROM qa_checklists`; - if (count[0].count === "0") { + const qaCount = await sql`SELECT count(*) FROM qa_checklists`; + if (qaCount[0].count === "0") { await sql` INSERT INTO qa_checklists (id, label, is_completed) VALUES - ('qa-cap', 'Supercap mounted & isolated', false), - ('qa-sas', 'Broadcom SAS seated in PCIe', false), - ('qa-cool', 'Waterblock pressure tested', false), - ('qa-zfs', 'ZFS pool scrub baseline passed', false) + ('qa-inserts', 'Thermal insertion of 56x M3/M4 brass threaded inserts into 3D facade & fan wall', false), + ('qa-frame', '2020 Aluminum frame assembly and squaring (Torque M5 bolts to 4.5 N·m)', false), + ('qa-fans', '3x 120mm PWM fan wall installation with EPDM pneumatic perimeter gasket', false), + ('qa-pcba', 'Baseboard PCBA mounting and MCIO 8i twinaxial cable routing', false), + ('qa-skins', 'SendCutSend 5052 aluminum skins fastening & 6U rack ear installation', false), + ('qa-supercap', 'Supercapacitor PLP holdup validation (LTC3350 PFI) & Rust firmware flashing', false) `; } - console.log("✅ Database schema initialized successfully"); + + // 2. Bill of Materials (BOM) & Supply Chain + await sql` + CREATE TABLE IF NOT EXISTS bom_items ( + id VARCHAR(50) PRIMARY KEY, + component_name TEXT NOT NULL, + mpn VARCHAR(100) NOT NULL, + vendor VARCHAR(100) NOT NULL, + unit_price NUMERIC(10,2) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + lead_time_days INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'in_stock', + category VARCHAR(50) NOT NULL, + notes TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `; + + const bomCount = await sql`SELECT count(*) FROM bom_items`; + if (bomCount[0].count === "0") { + await sql` + INSERT INTO bom_items (id, component_name, mpn, vendor, unit_price, quantity, lead_time_days, status, category, notes) VALUES + ('bom-cm3i', 'Radxa CM3I SoM (4G/16G)', 'CM3I-4G16G', 'Ameridroid', 38.00, 1, 3, 'in_stock', 'compute', 'Rockchip RK3568 quad-core host'), + ('bom-sas3408', 'Broadcom SAS3408 Tri-Mode IOC', 'SAS3408-BGA484', 'Avnet / Arrow', 32.00, 1, 14, 'ordered', 'storage', 'PCIe 3.1 x8 to 8-PHY SAS/SATA/NVMe'), + ('bom-rp2040', 'RP2040 Dual MCU', 'RP2040-QFN56', 'DigiKey', 0.95, 1, 2, 'in_stock', 'compute', 'OpenBMC & hardware supervisor'), + ('bom-ltc3350', 'LTC3350 Supercap Manager IC', 'LTC3350EUHF#PBF', 'Mouser', 7.45, 1, 5, 'in_stock', 'power', 'Supercap PLP buck-boost balancer'), + ('bom-supercaps', 'Eaton 10.0F 2.7V Supercapacitors', 'B0510-2R7105-R', 'Mouser', 4.40, 4, 2, 'in_stock', 'power', '4-cell series stack (10.8V holdup)'), + ('bom-efuse', 'TPS25982 15A Smart eFuse', 'TPS259827ONRGER', 'DigiKey', 1.85, 2, 2, 'in_stock', 'power', 'Hot-swap inrush limiter & fault clamp'), + ('bom-diode', 'LM74700 Ideal Diode Controller', 'LM74700QDBVRQ1', 'Mouser', 0.95, 2, 2, 'in_stock', 'power', 'Reverse polarity & low-loss ORing'), + ('bom-mcio', 'Amphenol MCIO 8i Connectors (74-Pin)', 'G88MP08102CEEU', 'DigiKey', 7.60, 2, 4, 'in_stock', 'cables', 'PCIe Gen4 / SAS4 high-density interconnect'), + ('bom-u3', 'Amphenol U.3 SFF-TA-1001 Sockets', '10148784-101LF', 'Mouser', 11.20, 8, 7, 'in_stock', 'storage', 'Tri-mode SAS/SATA/NVMe drive bays'), + ('bom-pcb-base', 'Carrier Baseboard 6-Layer PCBA', 'PCB-BASE-01', 'MacroFab', 16.50, 1, 12, 'ordered', 'mechanical', 'Controlled impedance ENIG finish'), + ('bom-pcb-bp', 'Passive U.3 Drive Backplane PCBA', 'PCB-BP-01', 'MacroFab', 12.50, 1, 12, 'ordered', 'mechanical', '2oz copper power distribution plane'), + ('bom-2020', '2020 T-Slot Aluminum Extrusions', '20-2020-KIT', '80/20 Inc.', 14.00, 1, 3, 'in_stock', 'mechanical', 'Anodized 6063-T6 framing'), + ('bom-skins', 'SendCutSend 5052 Aluminum Outer Panels', 'SMP-ALL-01', 'SendCutSend', 26.80, 1, 4, 'in_stock', 'mechanical', 'Laser cut 2.0mm 5052-H32 with countersinks'), + ('bom-fasteners', 'Black Oxide M3/M4 Fastener & Insert Kit', 'FAST-KIT-01', 'McMaster-Carr', 16.00, 1, 1, 'in_stock', 'fasteners', 'Class 12.9 alloy steel hardware') + `; + } + + // 3. 3D Print Farm Jobs + await sql` + CREATE TABLE IF NOT EXISTS print_farm_jobs ( + id VARCHAR(50) PRIMARY KEY, + part_name TEXT NOT NULL, + printer_model VARCHAR(50) NOT NULL, + material VARCHAR(30) NOT NULL, + print_time_hours NUMERIC(4,1) NOT NULL, + weight_grams INTEGER NOT NULL, + cost_estimate NUMERIC(6,2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + bed_fit VARCHAR(30) NOT NULL, + notes TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `; + + const pfCount = await sql`SELECT count(*) FROM print_farm_jobs`; + if (pfCount[0].count === "0") { + await sql` + INSERT INTO print_farm_jobs (id, part_name, printer_model, material, print_time_hours, weight_grams, cost_estimate, status, bed_fit, notes) VALUES + ('pf-facade', '6U Hexagonal Intake Airflow Facade', 'Prusa Core One (L)', 'Prusament PETG V0', 14.5, 412, 14.20, 'completed', 'Fit (298x298mm)', 'Flame retardant UL94-V0 compliance'), + ('pf-fanwall', '3x 120mm Isolated PWM Fan Duct Wall', 'Bambu Lab X1-Carbon', 'Polymaker PA6-CF', 8.2, 245, 18.50, 'printing', 'Fit (250x250mm)', 'Carbon fiber stiffness to eliminate harmonic resonance'), + ('pf-caddy-1-4', 'U.3 Toolless Drive Caddies (Bays 1-4)', 'Voron 2.4 350', 'eSUN ABS+', 6.8, 180, 5.40, 'queued', 'Batch Fit (350x350mm)', 'Spring-loaded latch with light pipe inserts'), + ('pf-caddy-5-8', 'U.3 Toolless Drive Caddies (Bays 5-8)', 'Voron 2.4 350', 'eSUN ABS+', 6.8, 180, 5.40, 'queued', 'Batch Fit (350x350mm)', 'Spring-loaded latch with light pipe inserts'), + ('pf-shroud', 'Radxa CM3I & SAS3408 Passive Air Shroud', 'Bambu Lab X1-Carbon', 'Prusament PC-Blend', 4.1, 115, 8.90, 'queued', 'Fit (140x110mm)', '115°C HDT rating for high thermal exhaust ducting') + `; + } + + console.log("✅ All database schemas and seed data initialized successfully"); return; } catch (err: any) { console.warn(`⚠️ Database init attempt ${i + 1}/${retries} failed: ${err.message}`); diff --git a/src/db/printfarm.ts b/src/db/printfarm.ts new file mode 100644 index 0000000..06418ae --- /dev/null +++ b/src/db/printfarm.ts @@ -0,0 +1,37 @@ +import { sql } from "./connection.ts"; + +export interface PrintJob { + id: string; + part_name: string; + printer_model: string; + material: string; + print_time_hours: number; + weight_grams: number; + cost_estimate: number; + status: "completed" | "printing" | "queued"; + bed_fit: string; + notes?: string; + updated_at?: string; +} + +export const getPrintJobs = async (): Promise => { + return await sql` + SELECT * FROM print_farm_jobs ORDER BY + CASE status + WHEN 'printing' THEN 1 + WHEN 'queued' THEN 2 + WHEN 'completed' THEN 3 + ELSE 4 + END, + id ASC + `; +}; + +export const updatePrintJobStatus = async (id: string, status: string): Promise => { + return await sql` + UPDATE print_farm_jobs + SET status = ${status}, updated_at = CURRENT_TIMESTAMP + WHERE id = ${id} + RETURNING * + `; +}; diff --git a/src/routes/bom.ts b/src/routes/bom.ts new file mode 100644 index 0000000..c38710e --- /dev/null +++ b/src/routes/bom.ts @@ -0,0 +1,38 @@ +import { Hono } from "hono"; +import { getBomItems, getBomSummary, updateBomItemStatus } from "../db/bom.ts"; + +export const bomRoutes = new Hono(); + +bomRoutes.get("/", async (c) => { + try { + const category = c.req.query("category"); + const items = await getBomItems(category); + return c.json(items); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +bomRoutes.get("/summary", async (c) => { + try { + const summary = await getBomSummary(); + return c.json(summary); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +bomRoutes.put("/:id/status", async (c) => { + const id = c.req.param("id"); + try { + const body = await c.req.json(); + if (!body.status) { + return c.json({ error: "Missing status field" }, 400); + } + const updated = await updateBomItemStatus(id, body.status); + if (updated.length === 0) return c.json({ error: "BOM item not found" }, 404); + return c.json(updated[0]); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); diff --git a/src/routes/printfarm.ts b/src/routes/printfarm.ts new file mode 100644 index 0000000..c278877 --- /dev/null +++ b/src/routes/printfarm.ts @@ -0,0 +1,28 @@ +import { Hono } from "hono"; +import { getPrintJobs, updatePrintJobStatus } from "../db/printfarm.ts"; + +export const printFarmRoutes = new Hono(); + +printFarmRoutes.get("/", async (c) => { + try { + const jobs = await getPrintJobs(); + return c.json(jobs); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +printFarmRoutes.put("/:id/status", async (c) => { + const id = c.req.param("id"); + try { + const body = await c.req.json(); + if (!body.status) { + return c.json({ error: "Missing status field" }, 400); + } + const updated = await updatePrintJobStatus(id, body.status); + if (updated.length === 0) return c.json({ error: "Job not found" }, 404); + return c.json(updated[0]); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); diff --git a/viewer/bom.js b/viewer/bom.js new file mode 100644 index 0000000..1ee8a4d --- /dev/null +++ b/viewer/bom.js @@ -0,0 +1,87 @@ +// BOM & Supply Chain API Client +let liveBomData = []; + +async function loadBomData() { + try { + const res = await fetch('/api/bom'); + if (!res.ok) throw new Error("Failed to load BOM data"); + liveBomData = await res.json(); + + renderBomTable(); + updateBOMCalculations(); + } catch (err) { + console.error("BOM Loading Error:", err); + } +} + +function renderBomTable() { + const tbody = document.getElementById('bom-table-body'); + if (!tbody) return; + tbody.innerHTML = ''; + + liveBomData.forEach(item => { + const tr = document.createElement('tr'); + + let statusBadge = ' In Stock'; + if (item.status === 'ordered') { + statusBadge = ` Ordered (${item.lead_time_days}d)`; + } else if (item.status === 'backordered') { + statusBadge = ` Backorder (${item.lead_time_days}d)`; + } + + tr.innerHTML = ` + + ${item.component_name} +
${item.notes || ''}
+ + + ${item.vendor} +
${statusBadge}
+ + ${item.mpn} + $${parseFloat(item.unit_price).toFixed(2)} + `; + tbody.appendChild(tr); + }); +} + +function updateBOMCalculations() { + const batchSlider = document.getElementById('batch-slider'); + const batchQty = batchSlider ? parseInt(batchSlider.value) : 100; + const batchLabel = document.getElementById('batch-qty-label'); + if (batchLabel) batchLabel.innerText = `${batchQty} Units`; + + let discountFactor = 1.0; + if (batchQty >= 1000) discountFactor = 0.85; + else if (batchQty >= 100) discountFactor = 0.92; + else if (batchQty >= 10) discountFactor = 0.96; + + const baseTotal = liveBomData.length > 0 + ? liveBomData.reduce((acc, i) => acc + (parseFloat(i.unit_price) * (i.quantity || 1)), 0) + : 241.20; + + const unitCost = baseTotal * discountFactor; + const totalBatch = unitCost * batchQty; + + const unitEl = document.getElementById('rollup-unit-cost'); + const batchEl = document.getElementById('rollup-total-cost'); + if (unitEl) unitEl.innerText = `$${unitCost.toFixed(2)}`; + if (batchEl) batchEl.innerText = `$${totalBatch.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2})}`; +} + +function exportBOMCSV() { + let csv = "Component,MPN,Supplier,Quantity,UnitPriceUSD,Status,Notes\n"; + liveBomData.forEach(i => { + csv += `"${i.component_name}","${i.mpn}","${i.vendor}",${i.quantity || 1},${parseFloat(i.unit_price).toFixed(2)},"${i.status}","${i.notes || ''}"\n`; + }); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.setAttribute('href', url); + a.setAttribute('download', '6U_Storage_Array_Master_PO.csv'); + a.click(); +} + +window.loadBomData = loadBomData; +window.updateBOMCalculations = updateBOMCalculations; +window.exportBOMCSV = exportBOMCSV; diff --git a/viewer/index.html b/viewer/index.html index f77dae1..3c76c24 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -923,6 +923,8 @@ + +