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:
parent
0611091c8f
commit
f162e03978
@ -3,6 +3,8 @@
|
|||||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||||
Types: FEAT · FIX · UI · DATA · AI · INFRA
|
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, 171–302KB 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 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, 171–302KB 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 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."}
|
{"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
115
package-lock.json
generated
@ -13,7 +13,8 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.19",
|
"tsx": "^4.19",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@apify/consts": {
|
"node_modules/@apify/consts": {
|
||||||
@ -1765,6 +1766,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/adm-zip": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||||
@ -2133,6 +2144,20 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@ -2270,6 +2295,16 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"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": {
|
"node_modules/cron-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
@ -3097,6 +3145,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@ -5259,6 +5317,19 @@
|
|||||||
"node": ">= 10.x"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@ -5723,6 +5794,26 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"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": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.19",
|
"tsx": "^4.19",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { reviewRouter } from "./routes/review";
|
|||||||
import { stockRouter } from "./routes/stock";
|
import { stockRouter } from "./routes/stock";
|
||||||
import { priceComparisonRouter } from "./routes/price-comparison";
|
import { priceComparisonRouter } from "./routes/price-comparison";
|
||||||
import { selflearningRouter } from "./routes/selflearning";
|
import { selflearningRouter } from "./routes/selflearning";
|
||||||
|
import { internalDemandRouter } from "./routes/internal-demand";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -93,6 +94,8 @@ app.use("/api/review", reviewRouter);
|
|||||||
app.use("/api/stock", stockRouter);
|
app.use("/api/stock", stockRouter);
|
||||||
app.use("/api/price-comparison", priceComparisonRouter);
|
app.use("/api/price-comparison", priceComparisonRouter);
|
||||||
app.use("/api/selflearning", selflearningRouter);
|
app.use("/api/selflearning", selflearningRouter);
|
||||||
|
// Internal-only — restricted to localhost / LAN by the router itself
|
||||||
|
app.use("/api/internal/demand", internalDemandRouter);
|
||||||
|
|
||||||
// Dashboard (static HTML)
|
// Dashboard (static HTML)
|
||||||
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
|
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
|
||||||
|
|||||||
@ -84,13 +84,47 @@ forecastRouter.get("/:technology", async (req, res) => {
|
|||||||
const adoption12m = Math.min(1, adoptionNow + (proj?.[1]?.adoptionPct ?? 0) / 100);
|
const adoption12m = Math.min(1, adoptionNow + (proj?.[1]?.adoptionPct ?? 0) / 100);
|
||||||
const adoption18m = Math.min(1, adoptionNow + (proj?.[2]?.adoptionPct ?? 0) / 100);
|
const adoption18m = Math.min(1, adoptionNow + (proj?.[2]?.adoptionPct ?? 0) / 100);
|
||||||
|
|
||||||
const totalMarketPorts = tech.m * 1000000; // market potential in units
|
// Volume forecast — calibrated with real Flexoptix demand data where available
|
||||||
const marketShare = 0.03; // estimated Flexoptix-addressable share
|
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 realDemand = demandCalib.rows[0] as Record<string, string> | undefined;
|
||||||
const units9m = Math.round(totalMarketPorts * adoption9m * marketShare * 0.75);
|
const hasRealData = realDemand && parseFloat(realDemand["units_per_month_12m"] ?? "0") > 0;
|
||||||
const units12m = Math.round(totalMarketPorts * adoption12m * marketShare);
|
|
||||||
const units18m = Math.round(totalMarketPorts * adoption18m * marketShare * 1.5);
|
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
|
// Confidence decreases with forecast horizon
|
||||||
const conf3m = Math.min(0.95, 0.85 + (priceHistory.rows.length / 100));
|
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),
|
price_history: priceHistory.rows.slice(0, 12),
|
||||||
model: "Norton-Bass Multigenerational Diffusion v1",
|
model: "Norton-Bass Multigenerational Diffusion v1",
|
||||||
|
demand_calibrated: hasRealData ?? false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Forecast error:", err);
|
console.error("Forecast error:", err);
|
||||||
|
|||||||
239
packages/api/src/routes/internal-demand.ts
Normal file
239
packages/api/src/routes/internal-demand.ts
Normal 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 0–1
|
||||||
|
* - 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 (0–1), 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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 style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2>
|
<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 & more) · <span style="color:#f59e0b">⚠ Lagermengen & 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 & 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>
|
</div>
|
||||||
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
|
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1839,11 +1839,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
|
||||||
<!-- Top Sellers -->
|
<!-- Top Sellers -->
|
||||||
<div class="card" style="overflow:hidden">
|
<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.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 sind synthetische Seed-Daten — keine echten Flexoptix-Verkaufszahlen</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">
|
<div style="overflow-x:auto">
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-top-sellers">
|
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-top-sellers">
|
||||||
<thead>
|
<thead>
|
||||||
@ -7006,6 +7063,84 @@ async function loadStock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stockLoaded = true;
|
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 (10–99)', slow_mover: '🐢 Slow (1–9)', 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) {
|
} catch(e) {
|
||||||
console.error('loadStock error', e);
|
console.error('loadStock error', e);
|
||||||
}
|
}
|
||||||
|
|||||||
282
scripts/import-flexoptix-demand.ts
Normal file
282
scripts/import-flexoptix-demand.ts
Normal 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 (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);
|
||||||
|
});
|
||||||
71
sql/099-flexoptix-internal-demand-schema.sql
Normal file
71
sql/099-flexoptix-internal-demand-schema.sql
Normal 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).';
|
||||||
Loading…
x
Reference in New Issue
Block a user