transceiver-db/scripts/import-flexoptix-demand.ts
Rene Fichtmueller f162e03978 feat: Flexoptix internal demand intelligence + real forecast calibration
- 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
2026-04-25 17:44:20 +02:00

283 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (1099): ${s.regular}`);
console.log(` Slow movers (19): ${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);
});