- Migration 099: flexoptix_internal_demand table with RLS + v_demand_by_speed view - Import script: AES-256-CBC decrypt → parse 8585 SKUs → upsert with velocity class - 279 SKUs cross-referenced to transceiver catalog; 1288 with real demand data - New /api/internal/demand/* routes (by-speed, velocity, hype-weights, forecast-input) — protected by JWT auth + localhost/LAN IP restriction middleware - Forecast engine calibrated with real Flexoptix run-rates (demand_calibrated flag) - Dashboard: real Flexoptix Sales Velocity panel replaces DEMO DATA in Warehouse tab with momentum indicators, velocity class breakdown, trend arrows - Security: data stays on private server; RLS enforces is_internal=TRUE at DB layer
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
||
* 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=<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<Record<string, unknown>>(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<void> {
|
||
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<string, string>(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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
});
|