/** * Import Flexoptix Internal Demand Data * ────────────────────────────────────── * Decrypts the AES-256-CBC encrypted XLSX export from Flexoptix, * parses the "Artikel" sheet, cross-references the transceivers table, * and upserts demand data into flexoptix_internal_demand. * * ⚠️ SECURITY: Run only on local infrastructure. * The XLSX content NEVER leaves local servers. * Output DB is on Erik (private server, not a cloud). * * Usage: * XLSX_ENC=~/.credentials/flexoptix-stock.xlsx.enc \ * CRED_KEY= \ * npx tsx scripts/import-flexoptix-demand.ts * * Or with keychain (macOS): * CRED_KEY=$(security find-generic-password -a fearghas-cred-key -s fearghas-cred-key -w) \ * XLSX_ENC=~/.credentials/flexoptix-stock.xlsx.enc \ * npx tsx scripts/import-flexoptix-demand.ts */ import { execSync } from "child_process"; import { existsSync, writeFileSync, unlinkSync } from "fs"; import { join } from "path"; import { tmpdir, homedir } from "os"; import { Pool } from "pg"; import xlsx from "xlsx"; // ─── Config ────────────────────────────────────────────────────────────────── const ENC_PATH = (process.env.XLSX_ENC ?? join(homedir(), ".credentials/flexoptix-stock.xlsx.enc")) .replace(/^~/, homedir()); const CRED_KEY = process.env.CRED_KEY ?? ""; const TMP_XLSX = join(tmpdir(), `flexoptix-import-${Date.now()}.xlsx`); const DB_CONFIG = { host: process.env.TIP_DB_HOST ?? "82.165.222.127", port: parseInt(process.env.TIP_DB_PORT ?? "5432", 10), database: process.env.TIP_DB_NAME ?? "transceiver_db", user: process.env.TIP_DB_USER ?? "tip", password: process.env.TIP_DB_PASS ?? "", ssl: false, }; // Column headers in the XLSX (exact match) const COL_SKU = "Artikel"; const COL_DESC = "Kurzbeschreibung"; const COL_DEMAND_12M = "Bedarf pro Monat Letzte 12 Monate (FO Lager)"; const COL_DEMAND_3M = "Bedarf pro Monat Letzte 3 Monate (FO Lager)"; // ─── Types ──────────────────────────────────────────────────────────────────── interface DemandRow { sku: string; description: string; demand_12m: number; demand_3m: number; demand_trend_pct: number | null; velocity_class: "fast_mover" | "regular" | "slow_mover" | "dead_stock"; transceiver_id: string | null; } // ─── Decrypt ────────────────────────────────────────────────────────────────── function decryptXlsx(): void { if (!existsSync(ENC_PATH)) { throw new Error(`Encrypted file not found: ${ENC_PATH}`); } if (!CRED_KEY) { throw new Error("CRED_KEY is empty — set env var or source from Keychain"); } console.log(`🔓 Decrypting ${ENC_PATH} → ${TMP_XLSX}`); execSync( `openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 -in "${ENC_PATH}" -out "${TMP_XLSX}" -pass env:CRED_KEY`, { env: { ...process.env, CRED_KEY }, stdio: "pipe" } ); } function cleanupTmp(): void { if (existsSync(TMP_XLSX)) { unlinkSync(TMP_XLSX); console.log("🗑️ Temp XLSX removed from disk"); } } // ─── Parse ──────────────────────────────────────────────────────────────────── function classifyVelocity(demand12m: number): DemandRow["velocity_class"] { if (demand12m >= 100) return "fast_mover"; if (demand12m >= 10) return "regular"; if (demand12m > 0) return "slow_mover"; return "dead_stock"; } function parseXlsx(): DemandRow[] { console.log("📊 Parsing XLSX…"); const wb = xlsx.readFile(TMP_XLSX); const ws = wb.Sheets["Artikel"]; if (!ws) throw new Error('Sheet "Artikel" not found in XLSX'); const raw = xlsx.utils.sheet_to_json>(ws, { defval: 0 }); console.log(` ${raw.length} rows read`); const rows: DemandRow[] = []; for (const r of raw) { const sku = String(r[COL_SKU] ?? "").trim(); if (!sku) continue; const demand_12m = Math.max(0, parseFloat(String(r[COL_DEMAND_12M] ?? "0")) || 0); const demand_3m = Math.max(0, parseFloat(String(r[COL_DEMAND_3M] ?? "0")) || 0); const description = String(r[COL_DESC] ?? "").trim(); const demand_trend_pct = demand_12m > 0 ? Math.round(((demand_3m - demand_12m) / demand_12m) * 10000) / 100 : null; rows.push({ sku, description, demand_12m, demand_3m, demand_trend_pct, velocity_class: classifyVelocity(demand_12m), transceiver_id: null, // resolved in cross-reference step }); } const withDemand = rows.filter(r => r.demand_12m > 0 || r.demand_3m > 0); console.log(` ${rows.length} total articles | ${withDemand.length} with demand > 0`); return rows; } // ─── Cross-reference ────────────────────────────────────────────────────────── async function crossReference(rows: DemandRow[], pool: Pool): Promise { console.log("🔗 Cross-referencing with transceivers table…"); // Build local lookup: part_number (lower) → uuid const { rows: tRows } = await pool.query<{ id: string; part_number: string }>( `SELECT id, LOWER(part_number) AS part_number FROM transceivers` ); const lookup = new Map(tRows.map(t => [t.part_number, t.id])); let matched = 0; for (const row of rows) { const uuid = lookup.get(row.sku.toLowerCase()); if (uuid) { row.transceiver_id = uuid; matched++; } } console.log(` ${matched}/${rows.length} SKUs matched to transceivers`); } // ─── Upsert ─────────────────────────────────────────────────────────────────── async function upsertRows(rows: DemandRow[], pool: Pool): Promise { console.log(`💾 Upserting ${rows.length} rows into flexoptix_internal_demand…`); const BATCH = 500; let inserted = 0; let updated = 0; for (let i = 0; i < rows.length; i += BATCH) { const batch = rows.slice(i, i + BATCH); const client = await pool.connect(); try { await client.query("BEGIN"); for (const row of batch) { const r = await client.query( `INSERT INTO flexoptix_internal_demand (sku, description, transceiver_id, demand_12m, demand_3m, demand_trend_pct, velocity_class, is_internal) VALUES ($1,$2,$3,$4,$5,$6,$7,TRUE) ON CONFLICT (sku) DO UPDATE SET description = EXCLUDED.description, transceiver_id = EXCLUDED.transceiver_id, demand_12m = EXCLUDED.demand_12m, demand_3m = EXCLUDED.demand_3m, demand_trend_pct = EXCLUDED.demand_trend_pct, velocity_class = EXCLUDED.velocity_class, imported_at = NOW() RETURNING (xmax = 0) AS is_insert`, [row.sku, row.description, row.transceiver_id, row.demand_12m, row.demand_3m, row.demand_trend_pct, row.velocity_class] ); if (r.rows[0]?.is_insert) inserted++; else updated++; } await client.query("COMMIT"); } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } process.stdout.write(`\r ${Math.min(i + BATCH, rows.length)}/${rows.length} rows processed…`); } console.log(`\n ✅ ${inserted} inserted | ${updated} updated`); } // ─── Stats ──────────────────────────────────────────────────────────────────── async function printStats(pool: Pool): Promise { const { rows } = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE demand_12m > 0) AS with_demand, COUNT(*) FILTER (WHERE transceiver_id IS NOT NULL) AS matched_transceivers, COUNT(*) FILTER (WHERE velocity_class = 'fast_mover') AS fast_movers, COUNT(*) FILTER (WHERE velocity_class = 'regular') AS regular, COUNT(*) FILTER (WHERE velocity_class = 'slow_mover') AS slow_movers, COUNT(*) FILTER (WHERE velocity_class = 'dead_stock') AS dead_stock, ROUND(SUM(demand_12m)) AS total_monthly_demand_12m, ROUND(SUM(demand_3m)) AS total_monthly_demand_3m, MAX(demand_12m) AS peak_demand_12m FROM flexoptix_internal_demand `); const s = rows[0]!; console.log("\n📈 Import Summary:"); console.log(` Total SKUs: ${s.total}`); console.log(` With demand > 0: ${s.with_demand}`); console.log(` Matched transceivers: ${s.matched_transceivers}`); console.log(` Fast movers (≥100): ${s.fast_movers}`); console.log(` Regular (10–99): ${s.regular}`); console.log(` Slow movers (1–9): ${s.slow_movers}`); console.log(` Dead stock (0): ${s.dead_stock}`); console.log(` Total demand/month (12m): ${s.total_monthly_demand_12m} units`); console.log(` Total demand/month (3m): ${s.total_monthly_demand_3m} units`); console.log(` Peak demand (12m): ${s.peak_demand_12m} units/month`); } // ─── Main ───────────────────────────────────────────────────────────────────── async function main(): Promise { console.log("\n🔐 Flexoptix Internal Demand Importer"); console.log(" Data stays 100% on local infrastructure.\n"); if (!DB_CONFIG.password) { // Try to read from ~/.pgpass or env const pgpass = process.env.PGPASSWORD; if (pgpass) DB_CONFIG.password = pgpass; else { console.error("❌ TIP_DB_PASS / PGPASSWORD not set"); process.exit(1); } } const pool = new Pool(DB_CONFIG); try { // 1. Decrypt decryptXlsx(); // 2. Parse const rows = parseXlsx(); // 3. Cross-reference await crossReference(rows, pool); // 4. Upsert await upsertRows(rows, pool); // 5. Stats await printStats(pool); console.log("\n✅ Import complete — XLSX decrypted in-memory only, temp file wiped.\n"); } finally { cleanupTmp(); await pool.end(); } } main().catch(err => { console.error("❌ Import failed:", err); cleanupTmp(); process.exit(1); });