feat: Wire Supply Chain BOM and 3D Print Farm modules to Postgres and REST API

This commit is contained in:
Tyler G 2026-08-22 22:53:52 -07:00
parent 79559ba6f0
commit 6f06dd3782
9 changed files with 387 additions and 7 deletions

View File

@ -2,6 +2,8 @@ import { Hono } from "hono";
import { serveStatic } from "hono/deno"; import { serveStatic } from "hono/deno";
import { sql, initDb } from "./src/db/connection.ts"; import { sql, initDb } from "./src/db/connection.ts";
import { qaRoutes } from "./src/routes/qa.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(); const app = new Hono();
@ -25,6 +27,8 @@ app.get("/api/status", async (c) => {
// Mount modular sub-routes // Mount modular sub-routes
app.route("/api/qa", qaRoutes); app.route("/api/qa", qaRoutes);
app.route("/api/bom", bomRoutes);
app.route("/api/print-farm", printFarmRoutes);
// Serve the 3D Viewer frontend // Serve the 3D Viewer frontend
app.use("/*", serveStatic({ root: "./" })); app.use("/*", serveStatic({ root: "./" }));

50
src/db/bom.ts Normal file
View File

@ -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<BomItem[]> => {
if (category) {
return await sql<BomItem[]>`
SELECT * FROM bom_items WHERE category = ${category} ORDER BY category, component_name ASC
`;
}
return await sql<BomItem[]>`
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<BomItem[]> => {
return await sql<BomItem[]>`
UPDATE bom_items
SET status = ${status}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING *
`;
};

View File

@ -14,6 +14,7 @@ export const sql = postgres({
export async function initDb(retries = 5, delayMs = 2000) { export async function initDb(retries = 5, delayMs = 2000) {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
// 1. QA Checklists
await sql` await sql`
CREATE TABLE IF NOT EXISTS qa_checklists ( CREATE TABLE IF NOT EXISTS qa_checklists (
id VARCHAR(50) PRIMARY KEY, 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`; const qaCount = await sql`SELECT count(*) FROM qa_checklists`;
if (count[0].count === "0") { if (qaCount[0].count === "0") {
await sql` await sql`
INSERT INTO qa_checklists (id, label, is_completed) VALUES INSERT INTO qa_checklists (id, label, is_completed) VALUES
('qa-cap', 'Supercap mounted & isolated', false), ('qa-inserts', 'Thermal insertion of 56x M3/M4 brass threaded inserts into 3D facade & fan wall', false),
('qa-sas', 'Broadcom SAS seated in PCIe', false), ('qa-frame', '2020 Aluminum frame assembly and squaring (Torque M5 bolts to 4.5 N·m)', false),
('qa-cool', 'Waterblock pressure tested', false), ('qa-fans', '3x 120mm PWM fan wall installation with EPDM pneumatic perimeter gasket', false),
('qa-zfs', 'ZFS pool scrub baseline passed', 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; return;
} catch (err: any) { } catch (err: any) {
console.warn(`⚠️ Database init attempt ${i + 1}/${retries} failed: ${err.message}`); console.warn(`⚠️ Database init attempt ${i + 1}/${retries} failed: ${err.message}`);

37
src/db/printfarm.ts Normal file
View File

@ -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<PrintJob[]> => {
return await sql<PrintJob[]>`
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<PrintJob[]> => {
return await sql<PrintJob[]>`
UPDATE print_farm_jobs
SET status = ${status}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING *
`;
};

38
src/routes/bom.ts Normal file
View File

@ -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);
}
});

28
src/routes/printfarm.ts Normal file
View File

@ -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);
}
});

87
viewer/bom.js Normal file
View File

@ -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 = '<span style="color:var(--accent-emerald); font-weight:600;"><i class="fa-solid fa-check"></i> In Stock</span>';
if (item.status === 'ordered') {
statusBadge = `<span style="color:var(--accent-amber); font-weight:600;"><i class="fa-solid fa-clock"></i> Ordered (${item.lead_time_days}d)</span>`;
} else if (item.status === 'backordered') {
statusBadge = `<span style="color:var(--accent-rose); font-weight:600;"><i class="fa-solid fa-triangle-exclamation"></i> Backorder (${item.lead_time_days}d)</span>`;
}
tr.innerHTML = `
<td>
<strong>${item.component_name}</strong>
<div style="font-size:0.65rem; color:var(--text-muted);">${item.notes || ''}</div>
</td>
<td>
${item.vendor}
<div>${statusBadge}</div>
</td>
<td><code style="color:var(--accent-cyan);">${item.mpn}</code></td>
<td style="font-weight:700; color:var(--accent-emerald);">$${parseFloat(item.unit_price).toFixed(2)}</td>
`;
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;

View File

@ -923,6 +923,8 @@
<!-- Master Interactive JavaScript Application --> <!-- Master Interactive JavaScript Application -->
<script src="./qa.js"></script> <script src="./qa.js"></script>
<script src="./bom.js"></script>
<script src="./printfarm.js"></script>
<script> <script>
let scene, camera, renderer, controls; let scene, camera, renderer, controls;
let groupSkeleton, groupSheets, groupFacade, groupDrives, groupFans, groupPcba, groupIsolatedPart; let groupSkeleton, groupSheets, groupFacade, groupDrives, groupFans, groupPcba, groupIsolatedPart;
@ -2223,6 +2225,8 @@
window.onload = function() { window.onload = function() {
loadQaChecklist(); loadQaChecklist();
loadBomData();
loadPrintFarmData();
init3D(); init3D();
updatePrintFarmStats(); updatePrintFarmStats();
updateMaterialPhysics(); updateMaterialPhysics();

61
viewer/printfarm.js Normal file
View File

@ -0,0 +1,61 @@
// 3D Print Farm API Client
let livePrintJobs = [];
async function loadPrintFarmData() {
try {
const res = await fetch('/api/print-farm');
if (!res.ok) throw new Error("Failed to load Print Farm data");
livePrintJobs = await res.json();
renderPrintFarmQueue();
updatePrintFarmStats();
} catch (err) {
console.error("Print Farm Loading Error:", err);
}
}
function renderPrintFarmQueue() {
let container = document.getElementById('pf-jobs-container');
if (!container) {
const card = document.querySelector('#print-farm .card');
if (card) {
const queueCard = document.createElement('div');
queueCard.className = 'card';
queueCard.innerHTML = `
<div class="card-header"><i class="fa-solid fa-list-check"></i> Active Print Farm Job Queue</div>
<div id="pf-jobs-container" style="display:flex; flex-direction:column; gap:0.5rem;"></div>
`;
card.parentNode.insertBefore(queueCard, card.nextSibling);
container = document.getElementById('pf-jobs-container');
}
}
if (!container) return;
container.innerHTML = '';
livePrintJobs.forEach(job => {
const div = document.createElement('div');
div.style.cssText = 'background:#0b0f17; padding:0.6rem 0.8rem; border-radius:6px; font-size:0.75rem; display:flex; justify-content:space-between; align-items:center;';
let statusBadge = '<span style="color:var(--accent-emerald); font-weight:700;"><i class="fa-solid fa-check"></i> Complete</span>';
if (job.status === 'printing') {
statusBadge = '<span style="color:var(--accent-cyan); font-weight:700;"><i class="fa-solid fa-spinner fa-spin"></i> Printing</span>';
} else if (job.status === 'queued') {
statusBadge = '<span style="color:var(--text-muted); font-weight:600;"><i class="fa-solid fa-hourglass"></i> Queued</span>';
}
div.innerHTML = `
<div>
<strong>${job.part_name}</strong>
<div style="font-size:0.65rem; color:var(--text-muted);">${job.printer_model} ${job.material} ${job.weight_grams}g</div>
</div>
<div style="text-align:right;">
${statusBadge}
<div style="font-size:0.65rem; color:var(--accent-amber); font-weight:600;">${job.print_time_hours}h ($${parseFloat(job.cost_estimate).toFixed(2)})</div>
</div>
`;
container.appendChild(div);
});
}
window.loadPrintFarmData = loadPrintFarmData;