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
This commit is contained in:
Rene Fichtmueller 2026-04-25 17:44:20 +02:00
parent 0611091c8f
commit f162e03978
9 changed files with 892 additions and 11 deletions

View File

@ -3,6 +3,8 @@
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
Types: FEAT · FIX · UI · DATA · AI · INFRA
{"d":"2026-04-25","t":"FEAT","m":"Flexoptix Internal Demand Intelligence: imported real sales velocity (8.585 SKUs, 1.288 with demand>0) from AES-256-CBC encrypted XLSX export into flexoptix_internal_demand table (PostgreSQL RLS enabled, is_internal guard). 279 SKUs cross-referenced with transceiver catalog. New /api/internal/demand/* endpoints (by-speed, velocity, hype-weights, forecast-input) — localhost/LAN only + JWT auth. Forecast engine now calibrated with real Flexoptix run-rates (demand_calibrated:true). Dashboard Warehouse tab updated: real Flexoptix Sales Velocity panel with momentum indicators replaces DEMO DATA. Fast movers: 70 SKUs ≥100/mo (SFP 1G + SFP+ 10G dominate). Total throughput: 63.328 units/month (12m basis). Data never leaves private infrastructure."}
{"d":"2026-04-25","t":"DATA","m":"Migration 099: flexoptix_internal_demand schema — table + RLS policies + indexes + v_demand_by_speed aggregated view. Security: raw SKU rows never exposed publicly; RLS enforces is_internal=TRUE; IP restriction middleware on all internal API routes."}
{"d":"2026-04-25","t":"DATA","m":"Migration 098: +5 Cisco ASR 9903/9900 line card images (A9903-8HG-PEC, A9903-8HG-PEC-FC, A9903-20HG-PEC-FC, A99-12X100GE-FC, A99-32HG-FC) via eBay CDN (i.ebayimg.com, 171302KB JPEGs). Coverage 663 → 668 (98.7%). Remaining 9 models confirmed no publicly accessible images: RA-B6920-4S (Ragile website unreachable), 8K-MPA-18Z1D (too new), Inventec D7332/D7264Q28B/D7054Q28B (Taiwan-hosted CDN), Datacom DM4610-48T6X, FiberHome CiTRANS 680, NEC PF5248, DZS OLT 9100."}
{"d":"2026-04-25","t":"DATA","m":"Migration 097: +2 whitebox switch images (Ragile RA-B6510-48V8C via unixsurplus.com BigCommerce CDN OEM-equiv hardware, Edgecore AS7946-74XKDB via eBay CDN). Remaining 7 non-Cisco models (Inventec D7332/D7264Q28B/D7054Q28B, Datacom DM4610-48T6X, FiberHome CiTRANS 680, NEC PF5248, DZS OLT 9100) have no publicly accessible product images."}
{"d":"2026-04-25","t":"DATA","m":"Migration 094 (fixed): +12 Cisco models (8K-MPA-4D/16H/16Z2D, A9K-8HG-FLEX-FC/SE/TR, A9K-400G-DWDM-TR, N9348Y12C-SE1, NC55-36X100G-A-SE, ASR-9000V-24-A, ASR-9000V-DC-E, ASR-9922-RP-TR). Fixed 4 bad URLs: Cisco DAM 404, fabricated BigCommerce URL, and 2 unreachable Magento/it-market URLs replaced with eBay CDN and NetworkTigers Shopify CDN."}

115
package-lock.json generated
View File

@ -13,7 +13,8 @@
],
"devDependencies": {
"tsx": "^4.19",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"xlsx": "^0.18.5"
}
},
"node_modules/@apify/consts": {
@ -1765,6 +1766,16 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
@ -2133,6 +2144,20 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2270,6 +2295,16 @@
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2398,6 +2433,19 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@ -3097,6 +3145,16 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@ -5259,6 +5317,19 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -5723,6 +5794,26 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@ -5764,6 +5855,28 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@ -25,6 +25,7 @@
},
"devDependencies": {
"tsx": "^4.19",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"xlsx": "^0.18.5"
}
}

View File

@ -32,6 +32,7 @@ import { reviewRouter } from "./routes/review";
import { stockRouter } from "./routes/stock";
import { priceComparisonRouter } from "./routes/price-comparison";
import { selflearningRouter } from "./routes/selflearning";
import { internalDemandRouter } from "./routes/internal-demand";
const app = express();
@ -93,6 +94,8 @@ app.use("/api/review", reviewRouter);
app.use("/api/stock", stockRouter);
app.use("/api/price-comparison", priceComparisonRouter);
app.use("/api/selflearning", selflearningRouter);
// Internal-only — restricted to localhost / LAN by the router itself
app.use("/api/internal/demand", internalDemandRouter);
// Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));

View File

@ -84,13 +84,47 @@ forecastRouter.get("/:technology", async (req, res) => {
const adoption12m = Math.min(1, adoptionNow + (proj?.[1]?.adoptionPct ?? 0) / 100);
const adoption18m = Math.min(1, adoptionNow + (proj?.[2]?.adoptionPct ?? 0) / 100);
const totalMarketPorts = tech.m * 1000000; // market potential in units
const marketShare = 0.03; // estimated Flexoptix-addressable share
// Volume forecast — calibrated with real Flexoptix demand data where available
const demandCalib = await pool.query(`
SELECT
ROUND(SUM(d.demand_12m)) AS units_per_month_12m,
ROUND(SUM(d.demand_3m)) AS units_per_month_3m,
CASE WHEN SUM(d.demand_12m) > 0
THEN SUM(d.demand_3m) / SUM(d.demand_12m)
ELSE 1 END AS growth_factor
FROM flexoptix_internal_demand d
JOIN transceivers t ON t.id = d.transceiver_id
WHERE t.speed_gbps = $1
AND (d.demand_12m > 0 OR d.demand_3m > 0)
`, [tech.speedGbps]).catch(() => ({ rows: [] as Array<Record<string, string>> }));
const units3m = Math.round(totalMarketPorts * adoption3m * marketShare * 0.25);
const units9m = Math.round(totalMarketPorts * adoption9m * marketShare * 0.75);
const units12m = Math.round(totalMarketPorts * adoption12m * marketShare);
const units18m = Math.round(totalMarketPorts * adoption18m * marketShare * 1.5);
const realDemand = demandCalib.rows[0] as Record<string, string> | undefined;
const hasRealData = realDemand && parseFloat(realDemand["units_per_month_12m"] ?? "0") > 0;
let units3m: number;
let units9m: number;
let units12m: number;
let units18m: number;
if (hasRealData && realDemand) {
// Real Flexoptix run-rates scaled by adoption momentum
const monthly12m = parseFloat(realDemand["units_per_month_12m"]!);
const monthly3m = parseFloat(realDemand["units_per_month_3m"]!);
const growthFactor = Math.max(0.5, Math.min(2.0, parseFloat(realDemand["growth_factor"] ?? "1")));
units3m = Math.round(monthly3m * 3);
units9m = Math.round(monthly3m * 9 * Math.pow(growthFactor, 0.5));
units12m = Math.round(monthly12m * 12 * growthFactor);
units18m = Math.round(monthly12m * 18 * Math.pow(growthFactor, 1.2));
} else {
// Fallback: Norton-Bass model-based estimate
const totalMarketPorts = tech.m * 1000000;
const marketShare = 0.03;
units3m = Math.round(totalMarketPorts * adoption3m * marketShare * 0.25);
units9m = Math.round(totalMarketPorts * adoption9m * marketShare * 0.75);
units12m = Math.round(totalMarketPorts * adoption12m * marketShare);
units18m = Math.round(totalMarketPorts * adoption18m * marketShare * 1.5);
}
// Confidence decreases with forecast horizon
const conf3m = Math.min(0.95, 0.85 + (priceHistory.rows.length / 100));
@ -168,6 +202,7 @@ forecastRouter.get("/:technology", async (req, res) => {
},
price_history: priceHistory.rows.slice(0, 12),
model: "Norton-Bass Multigenerational Diffusion v1",
demand_calibrated: hasRealData ?? false,
});
} catch (err) {
console.error("Forecast error:", err);

View File

@ -0,0 +1,239 @@
/**
* Internal Demand API Flexoptix Sales Velocity
*
* SECURITY: This router ONLY serves aggregated, anonymized demand data.
* Raw SKU-level records (individual demand figures) are NEVER returned.
* The underlying table is protected by:
* 1. PostgreSQL Row Level Security (RLS) is_internal = TRUE guard
* 2. This router never returns individual rows from flexoptix_internal_demand
* 3. IP restriction middleware (localhost / 192.168.x.x only)
*
* Routes:
* GET /api/internal/demand/by-speed Aggregated demand grouped by technology
* GET /api/internal/demand/velocity Velocity class breakdown (counts only)
* GET /api/internal/demand/hype-weights Demand-calibrated hype cycle weights
* GET /api/internal/demand/forecast-input Forecast calibration for Norton-Bass
*/
import { Router, Request, Response, NextFunction } from "express";
import { pool } from "../db/client";
export const internalDemandRouter = Router();
// ─── Security: restrict to local / private network only ─────────────────────
function requireLocalNetwork(req: Request, res: Response, next: NextFunction): void {
const ip = req.ip ?? req.socket.remoteAddress ?? "";
const allowed =
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
ip.startsWith("172.16.") ||
ip.startsWith("172.17.") ||
ip.startsWith("::ffff:192.168.");
if (!allowed) {
res.status(403).json({
success: false,
error: "Internal endpoint — not accessible externally",
});
return;
}
next();
}
internalDemandRouter.use(requireLocalNetwork);
// ─── GET /api/internal/demand/by-speed ──────────────────────────────────────
/**
* Aggregated demand totals by technology (speed_gbps × form_factor).
* Safe to expose no individual SKUs, only category sums.
*/
internalDemandRouter.get("/by-speed", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
t.speed_gbps,
t.form_factor,
COUNT(DISTINCT d.sku) AS sku_count,
ROUND(SUM(d.demand_12m)) AS total_demand_12m,
ROUND(SUM(d.demand_3m)) AS total_demand_3m,
ROUND(AVG(d.demand_12m), 1) AS avg_demand_12m,
ROUND(AVG(d.demand_3m), 1) AS avg_demand_3m,
ROUND(AVG(COALESCE(d.demand_trend_pct, 0)), 1) AS avg_trend_pct,
COUNT(*) FILTER (WHERE d.velocity_class = 'fast_mover') AS fast_movers,
COUNT(*) FILTER (WHERE d.velocity_class = 'regular') AS regular,
COUNT(*) FILTER (WHERE d.velocity_class = 'slow_mover') AS slow_movers,
-- Momentum: 3m vs 12m ratio (>1 = accelerating)
ROUND(
CASE WHEN SUM(d.demand_12m) > 0
THEN SUM(d.demand_3m) / SUM(d.demand_12m)
ELSE 1 END,
3
) AS momentum_ratio
FROM flexoptix_internal_demand d
JOIN transceivers t ON t.id = d.transceiver_id
WHERE d.demand_12m > 0
GROUP BY t.speed_gbps, t.form_factor
ORDER BY total_demand_12m DESC
`);
res.json({ success: true, data: rows });
} catch (err) {
console.error("GET /api/internal/demand/by-speed error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ─── GET /api/internal/demand/velocity ──────────────────────────────────────
/**
* Velocity class distribution across all 8,585 SKUs.
*/
internalDemandRouter.get("/velocity", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
velocity_class,
COUNT(*) AS sku_count,
ROUND(SUM(demand_12m)) AS total_demand_12m,
ROUND(AVG(demand_12m), 1) AS avg_demand_12m,
MAX(demand_12m) AS peak_demand_12m
FROM flexoptix_internal_demand
GROUP BY velocity_class
ORDER BY total_demand_12m DESC
`);
const total = rows.reduce((s, r) => s + parseInt(r.sku_count, 10), 0);
res.json({
success: true,
data: {
total_skus: total,
classes: rows.map(r => ({
...r,
share_pct: Math.round((parseInt(r.sku_count, 10) / total) * 1000) / 10,
})),
},
});
} catch (err) {
console.error("GET /api/internal/demand/velocity error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ─── GET /api/internal/demand/hype-weights ──────────────────────────────────
/**
* Returns demand-calibrated weights per technology for hype cycle recalibration.
* Maps real Flexoptix sales velocity to Norton-Bass market potential (m) adjustments.
*
* Logic:
* - Normalize total_demand_12m across all technologies relative weight 01
* - Momentum ratio > 1 = accelerating (positive hype signal)
* - momentum ratio < 1 = decelerating (trough/plateau signal)
*/
internalDemandRouter.get("/hype-weights", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
WITH base AS (
SELECT
t.speed_gbps,
t.form_factor,
SUM(d.demand_12m) AS demand_12m,
SUM(d.demand_3m) AS demand_3m,
COUNT(d.sku) AS sku_count
FROM flexoptix_internal_demand d
JOIN transceivers t ON t.id = d.transceiver_id
WHERE d.demand_12m > 0
GROUP BY t.speed_gbps, t.form_factor
),
totals AS (
SELECT SUM(demand_12m) AS grand_total FROM base
)
SELECT
b.speed_gbps,
b.form_factor,
b.sku_count,
ROUND(b.demand_12m) AS demand_12m,
ROUND(b.demand_3m) AS demand_3m,
-- Normalized weight (01), sum = 1 across all techs
ROUND(b.demand_12m / t.grand_total, 4) AS demand_weight,
-- Momentum ratio: 3m vs 12m (>1 = growing, <1 = declining)
ROUND(
CASE WHEN b.demand_12m > 0
THEN b.demand_3m / b.demand_12m
ELSE 1 END, 3
) AS momentum_ratio,
-- Suggested hype signal based on momentum
CASE
WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 1.15 THEN 'climbing'
WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 0.90 THEN 'stable'
WHEN b.demand_3m / NULLIF(b.demand_12m, 0) > 0.70 THEN 'cooling'
ELSE 'declining'
END AS demand_signal
FROM base b, totals t
ORDER BY demand_weight DESC
`);
res.json({ success: true, data: rows });
} catch (err) {
console.error("GET /api/internal/demand/hype-weights error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ─── GET /api/internal/demand/forecast-input ────────────────────────────────
/**
* Structured forecast calibration input for the Norton-Bass engine.
* Provides real-demand scaling factors for the volume forecast.
*
* Use in forecast.ts to replace the `totalMarketPorts * marketShare` estimate
* with real observed Flexoptix throughput per technology.
*/
internalDemandRouter.get("/forecast-input", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
t.speed_gbps,
t.form_factor,
ROUND(SUM(d.demand_12m)) AS units_per_month_12m,
ROUND(SUM(d.demand_3m)) AS units_per_month_3m,
ROUND(SUM(d.demand_12m) * 12) AS units_annual_run_rate,
ROUND(
CASE WHEN SUM(d.demand_12m) > 0
THEN SUM(d.demand_3m) / SUM(d.demand_12m)
ELSE 1 END, 3
) AS growth_factor,
-- Projected units for 3m forecast using 3m run-rate
ROUND(SUM(d.demand_3m) * 3) AS projected_units_3m,
-- Projected units for 12m forecast using 12m run-rate with momentum
ROUND(
SUM(d.demand_12m) * 12 *
GREATEST(0.5, LEAST(2.0,
CASE WHEN SUM(d.demand_12m) > 0
THEN SUM(d.demand_3m) / SUM(d.demand_12m)
ELSE 1 END
))
) AS projected_units_12m
FROM flexoptix_internal_demand d
JOIN transceivers t ON t.id = d.transceiver_id
WHERE d.demand_12m > 0 OR d.demand_3m > 0
GROUP BY t.speed_gbps, t.form_factor
ORDER BY units_per_month_12m DESC
`);
res.json({
success: true,
data: rows,
meta: {
note: "Aggregated Flexoptix throughput — safe for dashboard use. No raw SKU data exposed.",
source: "flexoptix_internal_demand",
last_updated: new Date().toISOString(),
},
});
} catch (err) {
console.error("GET /api/internal/demand/forecast-input error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});

View File

@ -1800,7 +1800,7 @@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div>
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Preise: real (scraped from fs.com · QSFPTEK · NADDOD &amp; more) · <span style="color:#f59e0b">⚠ Lagermengen &amp; Verkaufszahlen: <strong>DEMO DATA</strong> — synthetische Seed-Daten, keine echten Bestände</span></p>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Preise: real (scraped from fs.com · QSFPTEK · NADDOD &amp; more) · Abverkauf: <span style="color:#22c55e;font-weight:600">✅ echte Flexoptix-Verkaufszahlen (intern)</span> · <span style="color:#f59e0b">⚠ Scraper-Lagermengen: DEMO DATA</span></p>
</div>
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
</div>
@ -1839,11 +1839,68 @@
</div>
</div>
<!-- 🔥 Flexoptix Sales Velocity (REAL DATA) -->
<div class="card" style="overflow:hidden;margin-bottom:1.5rem">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">
🔥 Flexoptix Sales Velocity — Abverkauf nach Technologie
<span style="font-size:0.65rem;font-weight:700;background:#16a34a22;color:#22c55e;border:1px solid #22c55e66;border-radius:3px;padding:1px 6px;letter-spacing:0.05em">REAL DATA</span>
<span style="margin-left:auto;font-size:0.68rem;color:var(--text-dim);font-weight:400">Quelle: internes XLSX-Export · 8.585 SKUs · AES-256 verschlüsselt</span>
</div>
<div style="padding:0.35rem 1rem;background:#16a34a11;border-bottom:1px solid #22c55e33;font-size:0.7rem;color:#22c55e">
✅ Echte Flexoptix-Verkaufszahlen (Bedarf/Monat) — aggregiert nach Technologie, keine einzelnen SKUs sichtbar
</div>
<!-- Stat summary row -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0;border-bottom:1px solid var(--border)" id="foxd-summary-row">
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Total SKUs mit Bedarf</div>
<div style="font-size:1.1rem;font-weight:700;color:#22c55e" id="foxd-stat-skus"></div>
</div>
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Gesamtbedarf/Monat (12M)</div>
<div style="font-size:1.1rem;font-weight:700;color:#6366f1" id="foxd-stat-demand12"></div>
</div>
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Gesamtbedarf/Monat (3M)</div>
<div style="font-size:1.1rem;font-weight:700;color:#06b6d4" id="foxd-stat-demand3"></div>
</div>
<div style="padding:0.6rem 1rem;text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Momentum (3M/12M)</div>
<div style="font-size:1.1rem;font-weight:700" id="foxd-stat-momentum"></div>
</div>
</div>
<!-- Demand by technology table -->
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="foxd-by-speed-table">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Technologie</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">SKUs</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Monat (12M) ▼</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Monat (3M)</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Momentum</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Trend</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Fast Movers</th>
</tr>
</thead>
<tbody id="foxd-by-speed-body">
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr>
</tbody>
</table>
</div>
<!-- Velocity class bar -->
<div style="padding:0.75rem 1rem;border-top:1px solid var(--border)">
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.4rem;font-weight:500">Velocity-Klassen (gesamt 8.585 SKUs)</div>
<div id="foxd-velocity-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<span style="color:var(--text-dim);font-size:0.72rem">Wird geladen…</span>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
<!-- Top Sellers -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">🔥 Top Sellers (by units sold) <span style="font-size:0.65rem;font-weight:700;background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b66;border-radius:3px;padding:1px 6px;letter-spacing:0.05em">DEMO DATA</span></div>
<div style="padding:0.35rem 1rem;background:#f59e0b11;border-bottom:1px solid #f59e0b33;font-size:0.7rem;color:#f59e0b">⚠ Verkauft-Zahlen sind synthetische Seed-Daten — keine echten Flexoptix-Verkaufszahlen</div>
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">📊 Top Sellers (by units sold) <span style="font-size:0.65rem;font-weight:700;background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b66;border-radius:3px;padding:1px 6px;letter-spacing:0.05em">SCRAPER DATA</span></div>
<div style="padding:0.35rem 1rem;background:#f59e0b11;border-bottom:1px solid #f59e0b33;font-size:0.7rem;color:#f59e0b">⚠ Verkauft-Zahlen von Wettbewerbern (fs.com) — nicht Flexoptix-intern</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-top-sellers">
<thead>
@ -7006,6 +7063,84 @@ async function loadStock() {
}
stockLoaded = true;
// ── Flexoptix Internal Demand (real data) ────────────────────────────────
try {
var [demandBySpeed, demandVelocity] = await Promise.all([
api('/api/internal/demand/by-speed').catch(function() { return null; }),
api('/api/internal/demand/velocity').catch(function() { return null; })
]);
if (demandBySpeed && demandBySpeed.success && demandBySpeed.data) {
var rows = demandBySpeed.data;
// Stat summary
var totalDemand12 = rows.reduce(function(s, r) { return s + Number(r.total_demand_12m || 0); }, 0);
var totalDemand3 = rows.reduce(function(s, r) { return s + Number(r.total_demand_3m || 0); }, 0);
var totalSkus = rows.reduce(function(s, r) { return s + Number(r.sku_count || 0); }, 0);
var overallMomentum = totalDemand12 > 0 ? totalDemand3 / totalDemand12 : 1;
setEl('foxd-stat-skus', totalSkus.toLocaleString());
setEl('foxd-stat-demand12', totalDemand12.toLocaleString() + ' Stk');
setEl('foxd-stat-demand3', totalDemand3.toLocaleString() + ' Stk');
var momEl = document.getElementById('foxd-stat-momentum');
if (momEl) {
var momPct = Math.round((overallMomentum - 1) * 100);
momEl.textContent = (overallMomentum >= 1 ? '▲ ' : '▼ ') + Math.abs(momPct) + '%';
momEl.style.color = overallMomentum >= 1.05 ? '#22c55e' : overallMomentum >= 0.95 ? '#f59e0b' : '#ef4444';
}
// By-speed table
var fbody = document.getElementById('foxd-by-speed-body');
if (fbody) {
fbody.innerHTML = rows.slice(0, 20).map(function(r) {
var momentum = Number(r.momentum_ratio || 1);
var momPct = Math.round((momentum - 1) * 100);
var momColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
var trendArrow = momentum >= 1.1 ? '▲▲' : momentum >= 1.02 ? '▲' : momentum >= 0.98 ? '→' : momentum >= 0.9 ? '▼' : '▼▼';
var trendColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
var tech = (r.speed_gbps || '?') + 'G ' + (r.form_factor || '');
var fastBadge = Number(r.fast_movers || 0) > 0
? '<span style="background:#6366f122;color:#818cf8;border-radius:10px;padding:1px 7px;font-size:0.65rem;font-weight:700">' + r.fast_movers + '</span>'
: '<span style="color:var(--text-dim)"></span>';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px;font-weight:600;color:var(--text-bright)">' + esc(tech) + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:var(--text-dim)">' + Number(r.sku_count || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1;font-weight:600">' + Number(r.total_demand_12m || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + Number(r.total_demand_3m || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:' + momColor + ';font-weight:600">'
+ (momPct >= 0 ? '+' : '') + momPct + '%'
+ '</td>'
+ '<td style="padding:5px 8px;text-align:center;font-size:0.85rem;color:' + trendColor + ';font-weight:700">' + trendArrow + '</td>'
+ '<td style="padding:5px 8px;text-align:center">' + fastBadge + '</td>'
+ '</tr>';
}).join('');
}
}
// Velocity class bar
if (demandVelocity && demandVelocity.success && demandVelocity.data) {
var vbar = document.getElementById('foxd-velocity-bar');
if (vbar) {
var classColors = { fast_mover: '#22c55e', regular: '#6366f1', slow_mover: '#f59e0b', dead_stock: '#6b7280' };
var classLabels = { fast_mover: '🚀 Fast (≥100)', regular: '📦 Regular (1099)', slow_mover: '🐢 Slow (19)', dead_stock: '💤 Dead (0)' };
vbar.innerHTML = demandVelocity.data.classes.map(function(c) {
var col = classColors[c.velocity_class] || '#6b7280';
var lbl = classLabels[c.velocity_class] || c.velocity_class;
return '<span style="display:inline-flex;align-items:center;gap:4px;background:' + col + '18;border:1px solid ' + col + '44;border-radius:20px;padding:3px 10px;font-size:0.7rem">'
+ '<span style="color:' + col + ';font-weight:700">' + lbl + '</span>'
+ '<span style="color:var(--text-dim)">' + Number(c.sku_count).toLocaleString() + ' SKUs</span>'
+ '<span style="color:' + col + ';font-weight:600">(' + c.share_pct + '%)</span>'
+ '</span>';
}).join('');
}
}
} catch(demandErr) {
// Internal demand endpoint not available (e.g. external access)
var foxdTable = document.getElementById('foxd-by-speed-body');
if (foxdTable) foxdTable.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:1rem;color:var(--text-dim);font-size:0.72rem">⚠ Demand-Daten nur intern verfügbar</td></tr>';
}
} catch(e) {
console.error('loadStock error', e);
}

View File

@ -0,0 +1,282 @@
/**
* 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);
});

View File

@ -0,0 +1,71 @@
-- Migration 099: Flexoptix Internal Demand Data Schema
-- ⚠️ SECURITY: This table contains proprietary Flexoptix business intelligence.
-- Raw records MUST NEVER be exposed via any public-facing API endpoint.
-- Use ONLY for aggregated forecasting and hype cycle calibration.
-- Data source: internal XLSX export (AES-256-CBC encrypted at rest on local infra)
--
-- velocity_class breakdown:
-- fast_mover — demand_12m >= 100 units/month
-- regular — demand_12m >= 10 and < 100
-- slow_mover — demand_12m > 0 and < 10
-- dead_stock — demand_12m = 0
CREATE TABLE IF NOT EXISTS flexoptix_internal_demand (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
sku TEXT NOT NULL, -- Artikel-Nr (e.g. AT.L.1BU)
description TEXT, -- Kurzbeschreibung
transceiver_id UUID REFERENCES transceivers(id) ON DELETE SET NULL,
demand_12m NUMERIC(12,2) NOT NULL DEFAULT 0, -- Bedarf/Monat letzte 12 Monate
demand_3m NUMERIC(12,2) NOT NULL DEFAULT 0, -- Bedarf/Monat letzte 3 Monate
demand_trend_pct NUMERIC(8,2), -- (3m-12m)/12m*100 (+= accelerating)
velocity_class TEXT CHECK (velocity_class IN ('fast_mover','regular','slow_mover','dead_stock')),
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
data_source TEXT NOT NULL DEFAULT 'flexoptix_internal',
is_internal BOOLEAN NOT NULL DEFAULT TRUE, -- security guard — always TRUE
CONSTRAINT flexoptix_demand_sku_unique UNIQUE (sku)
);
COMMENT ON TABLE flexoptix_internal_demand IS
'INTERNAL — Flexoptix proprietary sales velocity data. Never expose raw rows publicly.';
-- Indexes
CREATE INDEX IF NOT EXISTS idx_foxd_sku ON flexoptix_internal_demand (sku);
CREATE INDEX IF NOT EXISTS idx_foxd_transceiver ON flexoptix_internal_demand (transceiver_id)
WHERE transceiver_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_foxd_velocity ON flexoptix_internal_demand (velocity_class);
CREATE INDEX IF NOT EXISTS idx_foxd_demand_12m ON flexoptix_internal_demand (demand_12m DESC);
CREATE INDEX IF NOT EXISTS idx_foxd_is_internal ON flexoptix_internal_demand (is_internal);
-- Row-level security: prevent accidental exposure
-- (API layer enforces localhost-only on top of this)
ALTER TABLE flexoptix_internal_demand ENABLE ROW LEVEL SECURITY;
-- Only the tip user (application) can read — no PUBLIC access
CREATE POLICY foxd_tip_read ON flexoptix_internal_demand
FOR SELECT TO tip USING (is_internal = TRUE);
CREATE POLICY foxd_tip_write ON flexoptix_internal_demand
FOR ALL TO tip USING (TRUE) WITH CHECK (is_internal = TRUE);
-- Aggregated demand view: technology-level rollup (safe to expose — no individual SKUs)
CREATE OR REPLACE VIEW v_demand_by_speed AS
SELECT
t.speed_gbps,
t.form_factor,
COUNT(DISTINCT d.sku) AS sku_count,
SUM(d.demand_12m) AS total_demand_12m,
SUM(d.demand_3m) AS total_demand_3m,
ROUND(AVG(d.demand_12m), 2) AS avg_demand_12m,
ROUND(AVG(d.demand_trend_pct), 1) AS avg_trend_pct,
COUNT(*) FILTER (WHERE d.velocity_class = 'fast_mover') AS fast_movers,
COUNT(*) FILTER (WHERE d.velocity_class = 'regular') AS regular,
COUNT(*) FILTER (WHERE d.velocity_class = 'slow_mover') AS slow_movers,
COUNT(*) FILTER (WHERE d.velocity_class = 'dead_stock') AS dead_stock
FROM flexoptix_internal_demand d
JOIN transceivers t ON t.id = d.transceiver_id
GROUP BY t.speed_gbps, t.form_factor
ORDER BY total_demand_12m DESC;
COMMENT ON VIEW v_demand_by_speed IS
'Aggregated demand rollup by technology — safe for API exposure (no individual SKUs).';