feat: procurement — 5 intelligence sections (A-E)
E Buy-Now Intel 211k precomputed reorder signals surfaced,
filterable by form factor, signal strength bars
A Arbitrage 59k equivalence pairs + price data, FX vs comp
normalized to USD, sorted by savings %
B Switch Compat search 429 switches → compatible transceivers
with prices; 58k compatibility rows
C Supply Squeeze 4-signal detector: price momentum (30d vs 60d),
hype phase, AI cluster demand, stock pressure
D Dead Stock 7,297 dead-stock SKUs matched against ascending
hype phases (revival candidates)
5 new API endpoints: /api/procurement/reorder-top, /arbitrage,
/switch-compat, /supply-squeeze, /dead-stock-revival
This commit is contained in:
parent
4bd16af9a5
commit
bcab2b97af
@ -1,6 +1,7 @@
|
||||
# TIP Changelog
|
||||
|
||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Procurement: 5 neue Intelligence-Sektionen. (E) 🟢 Buy-Now Intel — Top buy_now Reorder Signals aus 211k preberechneten Signalen, filterbar nach Form Factor, Signalstärke-Balken, Preis/Stock-Trend, Gründe als Tooltip. API: GET /api/procurement/reorder-top. (A) 💰 Arbitrage — FX-Preis vs. Competitor-Preis für 59k Equivalenz-Paare mit Preisdaten auf beiden Seiten, normalisiert auf USD (EUR×1.08, GBP×1.27), sortiert nach Ersparnis-%. API: GET /api/procurement/arbitrage. (B) 🖥 Switch Compat — Suche nach Switch-Modell (Cisco, Juniper, Arista etc.), zeigt alle kompatiblen Transceiver mit Preis + Verifikationsmethode. 58k Compatibility-Rows, 429 Switches. API: GET /api/procurement/switch-compat?search=. (C) ⚠️ Supply Squeeze — Multi-Signal-Detektor: 4 parallele Quellen (Preis-Momentum 30d vs 60d, Hype-Phase, AI-Cluster-Transceiver-Nachfrage, Stock-Level-Verteilung). Severity: critical/warning/watch. API: GET /api/procurement/supply-squeeze. (D) 🪦 Dead Stock Revival — 7.297 Dead-Stock-SKUs gegen Hype-Cycle-Phasen: zeigt welche Lagerhüter in Technologieklassen liegen die gerade aufsteigen (ascending hype phases, score >30). API: GET /api/procurement/dead-stock-revival."}
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Crawler Intelligence: Data Quality panel. New GET /api/scrapers/data-quality endpoint — 4 parallel queries over 200,617 transceiver_verification_evidence rows: (1) coverage breakdown (price 11,366/18,146 = 62%, image 12,333/68%, details 17,085/94%, competitor_match 399/2%, quarantined 1,193); (2) all 10 evidence types with count + avg confidence + product count + last seen; (3) robot/scraper contributions table (17 robots ranked by output); (4) daily activity last 14 days. Dashboard Crawler Intelligence tab: new 🔬 Data Quality section with coverage progress bars (color-coded ≥80% green / ≥50% amber / red), evidence type table, SVG sparkline bar chart for 14-day activity, robot contributions table with live/stale dot indicators."}
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
|
||||
|
||||
@ -500,3 +500,375 @@ procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Respon
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── E: GET /api/procurement/reorder-top ─────────────────────────────────────
|
||||
// Top buy_now reorder signals with full reasons — 211k precomputed signals
|
||||
procurementRouter.get("/reorder-top", async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
const formFactor = (req.query.form_factor as string) || "";
|
||||
const minStrength = parseFloat(req.query.min_strength as string) || 0;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT DISTINCT ON (t.id)
|
||||
t.id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
|
||||
v.name AS vendor_name,
|
||||
rs.signal, rs.signal_strength,
|
||||
rs.price_trend, rs.stock_trend, rs.hype_phase,
|
||||
rs.reasons,
|
||||
rs.computed_at
|
||||
FROM reorder_signals rs
|
||||
JOIN transceivers t ON t.id = rs.transceiver_id
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE rs.signal = 'buy_now'
|
||||
AND rs.is_demo_data = false
|
||||
AND rs.signal_strength >= $1
|
||||
AND ($2 = '' OR t.form_factor ILIKE $2)
|
||||
ORDER BY t.id, rs.signal_strength DESC, rs.computed_at DESC
|
||||
`, [minStrength, formFactor]);
|
||||
|
||||
// After DISTINCT ON, re-sort by signal_strength
|
||||
const rows = result.rows.sort(
|
||||
(a: { signal_strength: string }, b: { signal_strength: string }) =>
|
||||
parseFloat(b.signal_strength) - parseFloat(a.signal_strength)
|
||||
);
|
||||
|
||||
const summary = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::int AS buy_now,
|
||||
COUNT(*) FILTER (WHERE signal = 'wait' AND is_demo_data = false)::int AS wait,
|
||||
COUNT(*) FILTER (WHERE signal = 'hold' AND is_demo_data = false)::int AS hold,
|
||||
COUNT(*) FILTER (WHERE signal = 'monitor' AND is_demo_data = false)::int AS monitor,
|
||||
ROUND(AVG(signal_strength) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::numeric,3) AS avg_buy_strength
|
||||
FROM reorder_signals
|
||||
`);
|
||||
|
||||
res.json({ success: true, data: rows.slice(0, limit), summary: summary.rows[0] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── B: GET /api/procurement/switch-compat ───────────────────────────────────
|
||||
// Switch ↔ transceiver compatibility matrix
|
||||
procurementRouter.get("/switch-compat", async (req: Request, res: Response) => {
|
||||
const search = (req.query.search as string) || "";
|
||||
const limitNum = Math.min(parseInt(req.query.limit as string) || 30, 100);
|
||||
|
||||
try {
|
||||
if (search.length >= 2) {
|
||||
// Search for switches matching query, return their compatible transceivers
|
||||
const switches = await pool.query(`
|
||||
SELECT DISTINCT ON (sw.id)
|
||||
sw.id, sw.vendor AS sw_vendor, sw.model AS sw_model, sw.series AS sw_series,
|
||||
COUNT(c.transceiver_id) OVER (PARTITION BY sw.id)::int AS compat_count
|
||||
FROM switches sw
|
||||
JOIN compatibility c ON c.switch_id = sw.id
|
||||
WHERE sw.model ILIKE $1 OR sw.vendor ILIKE $1 OR sw.series ILIKE $1
|
||||
ORDER BY sw.id, compat_count DESC
|
||||
LIMIT $2
|
||||
`, [`%${search}%`, limitNum]);
|
||||
|
||||
// For each matched switch, get top compatible transceivers with prices
|
||||
const switchIds = switches.rows.map((s: { id: string }) => s.id);
|
||||
if (switchIds.length === 0) {
|
||||
return res.json({ success: true, switches: [], transceivers: [] });
|
||||
}
|
||||
|
||||
const transceivers = await pool.query(`
|
||||
SELECT
|
||||
c.switch_id,
|
||||
t.id AS tx_id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
|
||||
v.name AS vendor_name,
|
||||
c.verification_method, c.status,
|
||||
(SELECT ROUND(MIN(po.price)::numeric,2) FROM price_observations po
|
||||
WHERE po.transceiver_id = t.id AND po.price > 0
|
||||
ORDER BY po.time DESC LIMIT 1) AS min_price,
|
||||
(SELECT po.currency FROM price_observations po
|
||||
WHERE po.transceiver_id = t.id AND po.price > 0
|
||||
ORDER BY po.time DESC LIMIT 1) AS currency
|
||||
FROM compatibility c
|
||||
JOIN transceivers t ON t.id = c.transceiver_id
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE c.switch_id = ANY($1)
|
||||
AND c.status = 'compatible'
|
||||
ORDER BY t.speed_gbps DESC, t.form_factor
|
||||
LIMIT 200
|
||||
`, [switchIds]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
switches: switches.rows,
|
||||
transceivers: transceivers.rows,
|
||||
});
|
||||
}
|
||||
|
||||
// No search — return top switches by compat count
|
||||
const top = await pool.query(`
|
||||
SELECT sw.vendor, sw.model, sw.series,
|
||||
COUNT(c.transceiver_id)::int AS compat_count
|
||||
FROM switches sw
|
||||
JOIN compatibility c ON c.switch_id = sw.id
|
||||
WHERE c.status = 'compatible'
|
||||
GROUP BY sw.id, sw.vendor, sw.model, sw.series
|
||||
ORDER BY compat_count DESC
|
||||
LIMIT $1
|
||||
`, [limitNum]);
|
||||
|
||||
const stats = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT sw.id)::int AS total_switches,
|
||||
COUNT(DISTINCT c.transceiver_id)::int AS total_transceivers,
|
||||
COUNT(*)::int AS total_compat_rows
|
||||
FROM switches sw JOIN compatibility c ON c.switch_id = sw.id
|
||||
WHERE c.status = 'compatible'
|
||||
`);
|
||||
|
||||
return res.json({ success: true, topSwitches: top.rows, stats: stats.rows[0] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── A: GET /api/procurement/arbitrage ───────────────────────────────────────
|
||||
// OEM vs Flexoptix price gaps via transceiver_equivalences
|
||||
procurementRouter.get("/arbitrage", async (_req: Request, res: Response) => {
|
||||
// FX rates for normalization — approximate
|
||||
const FX: Record<string, number> = { USD: 1.0, EUR: 1.08, GBP: 1.27 };
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
te.confidence,
|
||||
fx.part_number AS fx_part,
|
||||
vfx.name AS fx_vendor,
|
||||
fx.speed_gbps, fx.form_factor, fx.reach_label,
|
||||
comp.part_number AS comp_part,
|
||||
vcomp.name AS comp_vendor,
|
||||
(SELECT price FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_price,
|
||||
(SELECT currency FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_curr,
|
||||
(SELECT price FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_price,
|
||||
(SELECT currency FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_curr
|
||||
FROM transceiver_equivalences te
|
||||
JOIN transceivers fx ON fx.id = te.flexoptix_id
|
||||
JOIN transceivers comp ON comp.id = te.competitor_id
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
|
||||
WHERE te.status IN ('approved','auto_approved')
|
||||
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2)
|
||||
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2)
|
||||
ORDER BY te.confidence DESC
|
||||
LIMIT 2000
|
||||
`);
|
||||
|
||||
const pairs = result.rows
|
||||
.map((r: {
|
||||
fx_price: string; fx_curr: string;
|
||||
comp_price: string; comp_curr: string;
|
||||
confidence: string;
|
||||
fx_part: string; fx_vendor: string;
|
||||
comp_part: string; comp_vendor: string;
|
||||
speed_gbps: string; form_factor: string; reach_label: string;
|
||||
}) => {
|
||||
const fxUSD = parseFloat(r.fx_price) * (FX[r.fx_curr] || 1.0);
|
||||
const compUSD = parseFloat(r.comp_price) * (FX[r.comp_curr] || 1.0);
|
||||
if (!fxUSD || !compUSD) return null;
|
||||
const savings = compUSD - fxUSD;
|
||||
const savingsPct = Math.round((savings / compUSD) * 100);
|
||||
return { ...r, fxUSD: Math.round(fxUSD), compUSD: Math.round(compUSD), savings: Math.round(savings), savingsPct };
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null && r.savings > 0)
|
||||
.sort((a, b) => b.savingsPct - a.savingsPct)
|
||||
.slice(0, 100);
|
||||
|
||||
// Stats
|
||||
const totalPairs = result.rows.length;
|
||||
const fxCheaper = pairs.length;
|
||||
const avgSavings = pairs.length ? Math.round(pairs.reduce((s, r) => s + r.savingsPct, 0) / pairs.length) : 0;
|
||||
|
||||
res.json({ success: true, pairs, stats: { totalPairs, fxCheaper, avgSavingsPct: avgSavings } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── D: GET /api/procurement/dead-stock-revival ──────────────────────────────
|
||||
// Dead-stock SKUs whose equivalents are in rising hype phases
|
||||
procurementRouter.get("/dead-stock-revival", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [deadStock, hypeMap] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT
|
||||
fid.transceiver_id,
|
||||
fid.part_number_raw AS part_number,
|
||||
fid.velocity_class,
|
||||
fid.demand_12m,
|
||||
fid.demand_trend_pct,
|
||||
t.speed_gbps, t.form_factor, t.reach_label,
|
||||
v.name AS vendor_name
|
||||
FROM flexoptix_internal_demand fid
|
||||
JOIN transceivers t ON t.id = fid.transceiver_id
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE fid.velocity_class = 'dead_stock'
|
||||
AND fid.is_internal = true
|
||||
LIMIT 7500
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT DISTINCT ON (technology)
|
||||
technology, hype_phase, hype_score, computed_at
|
||||
FROM hype_cycle_analysis
|
||||
ORDER BY technology, computed_at DESC
|
||||
`),
|
||||
]);
|
||||
|
||||
// Build speed → hype phase map
|
||||
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
|
||||
const ASCENDING = new Set(["innovation_trigger","peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
|
||||
const speedToHype = new Map<number, HypeRow>();
|
||||
for (const h of hypeMap.rows as HypeRow[]) {
|
||||
const speedMatch = h.technology.match(/^(\d+(?:\.\d+)?)G/);
|
||||
if (speedMatch) speedToHype.set(parseFloat(speedMatch[1]), h);
|
||||
}
|
||||
|
||||
type DeadRow = {
|
||||
transceiver_id: string; part_number: string;
|
||||
speed_gbps: string; form_factor: string; reach_label: string;
|
||||
vendor_name: string; demand_12m: string; demand_trend_pct: string;
|
||||
velocity_class: string;
|
||||
};
|
||||
|
||||
const revivals = (deadStock.rows as DeadRow[])
|
||||
.map((r) => {
|
||||
const speed = parseFloat(r.speed_gbps);
|
||||
const hype = speedToHype.get(speed);
|
||||
if (!hype) return null;
|
||||
const ascending = ASCENDING.has(hype.hype_phase);
|
||||
const score = parseFloat(hype.hype_score);
|
||||
return { ...r, hype_phase: hype.hype_phase, hype_score: score, ascending };
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null && r.ascending && r.hype_score > 30)
|
||||
.sort((a, b) => b.hype_score - a.hype_score)
|
||||
.slice(0, 100);
|
||||
|
||||
const totalDead = deadStock.rows.length;
|
||||
res.json({ success: true, revivals, totalDeadStock: totalDead, revivalCount: revivals.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── C: GET /api/procurement/supply-squeeze ──────────────────────────────────
|
||||
// Multi-signal supply constraint detector
|
||||
procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [priceSignals, aiDemand, hypeData, stockData] = await Promise.all([
|
||||
// Price momentum: 30d vs 60d avg by speed/form_factor
|
||||
pool.query(`
|
||||
SELECT
|
||||
t.speed_gbps, t.form_factor,
|
||||
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days')::numeric,2) AS avg_30d,
|
||||
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days')::numeric,2) AS avg_prior_30d,
|
||||
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d
|
||||
FROM price_observations po
|
||||
JOIN transceivers t ON t.id = po.transceiver_id
|
||||
WHERE po.price > 5 AND po.currency = 'USD'
|
||||
GROUP BY t.speed_gbps, t.form_factor
|
||||
HAVING COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') >= 3
|
||||
`),
|
||||
// AI cluster demand by speed tier
|
||||
pool.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN description ILIKE '%800G%' THEN 800
|
||||
WHEN description ILIKE '%400G%' THEN 400
|
||||
WHEN description ILIKE '%100G%' THEN 100
|
||||
ELSE 0
|
||||
END AS speed_tier,
|
||||
COALESCE(SUM(estimated_transceivers),0)::int AS total_tx,
|
||||
COUNT(*)::int AS cluster_count
|
||||
FROM ai_cluster_announcements
|
||||
WHERE announced_date >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY speed_tier
|
||||
HAVING COALESCE(SUM(estimated_transceivers),0) > 0
|
||||
`),
|
||||
// Hype phase per technology
|
||||
pool.query(`
|
||||
SELECT DISTINCT ON (technology)
|
||||
technology, hype_phase, hype_score
|
||||
FROM hype_cycle_analysis ORDER BY technology, computed_at DESC
|
||||
`),
|
||||
// Stock level distribution (in_stock vs out_of_stock)
|
||||
pool.query(`
|
||||
SELECT
|
||||
t.speed_gbps, t.form_factor,
|
||||
COUNT(*) FILTER (WHERE so.stock_level = 'out_of_stock')::int AS out_of_stock,
|
||||
COUNT(*) FILTER (WHERE so.stock_level = 'in_stock')::int AS in_stock,
|
||||
COUNT(*)::int AS total_obs
|
||||
FROM stock_observations so
|
||||
JOIN transceivers t ON t.id = so.transceiver_id
|
||||
WHERE so.observed_at >= NOW() - INTERVAL '14 days'
|
||||
GROUP BY t.speed_gbps, t.form_factor
|
||||
HAVING COUNT(*) >= 3
|
||||
`).catch(() => ({ rows: [] })),
|
||||
]);
|
||||
|
||||
type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string };
|
||||
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
|
||||
type AiRow = { speed_tier: string; total_tx: string; cluster_count: string };
|
||||
type StockRow = { speed_gbps: string; form_factor: string; out_of_stock: string; in_stock: string; total_obs: string };
|
||||
|
||||
const speedToHype = new Map<number, HypeRow>();
|
||||
for (const h of hypeData.rows as HypeRow[]) {
|
||||
const m = h.technology.match(/^(\d+(?:\.\d+)?)G/);
|
||||
if (m) speedToHype.set(parseFloat(m[1]), h);
|
||||
}
|
||||
|
||||
const aiBySpeed = new Map<number, AiRow>();
|
||||
for (const a of aiDemand.rows as AiRow[]) {
|
||||
aiBySpeed.set(parseFloat(a.speed_tier), a);
|
||||
}
|
||||
|
||||
const stockByKey = new Map<string, StockRow>();
|
||||
for (const s of stockData.rows as StockRow[]) {
|
||||
stockByKey.set(`${s.speed_gbps}:${s.form_factor}`, s);
|
||||
}
|
||||
|
||||
const RISKY_PHASES = new Set(["peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
|
||||
|
||||
const signals = (priceSignals.rows as PriceRow[])
|
||||
.map((r) => {
|
||||
const speed = parseFloat(r.speed_gbps);
|
||||
const priceUp = r.avg_30d && r.avg_prior_30d
|
||||
? ((parseFloat(r.avg_30d) - parseFloat(r.avg_prior_30d)) / parseFloat(r.avg_prior_30d)) * 100
|
||||
: 0;
|
||||
const hype = speedToHype.get(speed);
|
||||
const ai = aiBySpeed.get(speed);
|
||||
const stock = stockByKey.get(`${r.speed_gbps}:${r.form_factor}`);
|
||||
|
||||
let activeSignals = 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (priceUp > 5) { activeSignals++; reasons.push(`Price +${Math.round(priceUp)}% (30d)`); }
|
||||
if (hype && RISKY_PHASES.has(hype.hype_phase)) { activeSignals++; reasons.push(`Hype: ${hype.hype_phase.replace(/_/g,' ')}`); }
|
||||
if (ai && parseInt(ai.total_tx) > 50000) { activeSignals++; reasons.push(`AI demand: ${parseInt(ai.total_tx).toLocaleString()} tx in 90d`); }
|
||||
if (stock && parseInt(stock.out_of_stock) > parseInt(stock.in_stock)) { activeSignals++; reasons.push(`Stock pressure: ${stock.out_of_stock}/${stock.total_obs} vendors OOS`); }
|
||||
|
||||
const severity = activeSignals >= 3 ? "critical" : activeSignals === 2 ? "warning" : activeSignals === 1 ? "watch" : "ok";
|
||||
return {
|
||||
speed_gbps: r.speed_gbps, form_factor: r.form_factor,
|
||||
avg_30d: r.avg_30d, avg_prior_30d: r.avg_prior_30d,
|
||||
price_momentum_pct: Math.round(priceUp),
|
||||
hype_phase: hype?.hype_phase || null,
|
||||
hype_score: hype ? parseFloat(hype.hype_score) : null,
|
||||
ai_demand_tx: ai ? parseInt(ai.total_tx) : 0,
|
||||
activeSignals, severity, reasons,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.activeSignals >= 1)
|
||||
.sort((a, b) => b.activeSignals - a.activeSignals || b.price_momentum_pct - a.price_momentum_pct);
|
||||
|
||||
res.json({ success: true, signals, criticalCount: signals.filter(s => s.severity === "critical").length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -1594,6 +1594,11 @@
|
||||
<!-- Sub-nav -->
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="showProcSection('signals')" id="proc-btn-signals" class="proc-btn proc-btn-active">Reorder Signals</button>
|
||||
<button onclick="showProcSection('reorder-top')" id="proc-btn-reorder-top" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">🟢 Buy-Now Intel</button>
|
||||
<button onclick="showProcSection('arbitrage')" id="proc-btn-arbitrage" class="proc-btn" style="background:rgba(59,130,246,0.08);border-color:rgba(59,130,246,0.3);color:#3b82f6">💰 Arbitrage</button>
|
||||
<button onclick="showProcSection('switch-compat')" id="proc-btn-switch-compat" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#818cf8">🖥 Switch Compat</button>
|
||||
<button onclick="showProcSection('supply-squeeze')" id="proc-btn-supply-squeeze" class="proc-btn" style="background:rgba(239,68,68,0.08);border-color:rgba(239,68,68,0.3);color:#ef4444">⚠️ Supply Squeeze</button>
|
||||
<button onclick="showProcSection('dead-stock')" id="proc-btn-dead-stock" class="proc-btn" style="background:rgba(245,158,11,0.08);border-color:rgba(245,158,11,0.3);color:#f59e0b">🪦 Dead Stock Revival</button>
|
||||
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
|
||||
<button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button>
|
||||
<button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button>
|
||||
@ -1604,6 +1609,54 @@
|
||||
<button onclick="loadProcurement()" style="background:var(--surface2);border:1px solid var(--border);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem;color:var(--text)">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- E: Buy-Now Intel -->
|
||||
<div id="proc-section-reorder-top" style="display:none">
|
||||
<div id="proc-reorder-top-summary" style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem;flex-wrap:wrap">
|
||||
<span style="font-size:0.75rem;color:var(--text-dim)">Form Factor:</span>
|
||||
<select id="reorder-ff-filter" onchange="reloadReorderTop()" style="font-size:0.75rem;padding:3px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text)">
|
||||
<option value="">All</option>
|
||||
<option>SFP+</option><option>QSFP28</option><option>QSFP-DD</option>
|
||||
<option>OSFP</option><option>SFP28</option><option>QSFP+</option>
|
||||
</select>
|
||||
<button onclick="reloadReorderTop()" style="margin-left:auto;font-size:0.72rem;padding:2px 10px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text-dim);cursor:pointer">↻</button>
|
||||
</div>
|
||||
<div id="proc-reorder-top-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- A: Arbitrage -->
|
||||
<div id="proc-section-arbitrage" style="display:none">
|
||||
<div style="padding:0.6rem 0.9rem;background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.25);border-radius:8px;font-size:0.75rem;color:#93c5fd;margin-bottom:1.25rem">
|
||||
💡 Zeigt Flexoptix-Preis vs. günstigster verfügbarer Preis für das gleiche Transceiver-Äquivalent. Preise normalisiert auf USD (EUR×1.08, GBP×1.27). Nur Paare mit Preisdaten auf beiden Seiten.
|
||||
</div>
|
||||
<div id="proc-arbitrage-stats" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||||
<div id="proc-arbitrage-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- B: Switch Compat -->
|
||||
<div id="proc-section-switch-compat" style="display:none">
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1.25rem;flex-wrap:wrap">
|
||||
<input id="switch-search-input" type="text" placeholder="Switch suchen: z.B. C9300, QFX5120, ACX7100…"
|
||||
onkeydown="if(event.key==='Enter')loadSwitchCompat()"
|
||||
style="flex:1;min-width:220px;padding:0.5rem 0.75rem;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:0.82rem">
|
||||
<button onclick="loadSwitchCompat()" style="padding:0.5rem 1rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-size:0.82rem;cursor:pointer;font-weight:600">Suchen</button>
|
||||
</div>
|
||||
<div id="proc-switch-stats" style="margin-bottom:1.25rem"></div>
|
||||
<div id="proc-switch-results"><div style="color:var(--text-dim)">Switch-Modell eingeben um kompatible Transceiver zu sehen.</div></div>
|
||||
</div>
|
||||
|
||||
<!-- C: Supply Squeeze -->
|
||||
<div id="proc-section-supply-squeeze" style="display:none">
|
||||
<div id="proc-squeeze-summary" style="margin-bottom:1.25rem"></div>
|
||||
<div id="proc-squeeze-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- D: Dead Stock Revival -->
|
||||
<div id="proc-section-dead-stock" style="display:none">
|
||||
<div id="proc-deadstock-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||||
<div id="proc-deadstock-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Reorder Signals section -->
|
||||
<div id="proc-section-signals">
|
||||
<div style="padding:0.5rem 0.75rem;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;font-size:0.72rem;color:#16a34a;margin-bottom:0.75rem">ℹ Reorder Signals basieren auf <strong>ABC-Klassifizierung + Preis-Observations-Frequenz</strong>. Echte Verkaufsmengendaten → <button onclick="showProcSection('demand')" style="background:none;border:none;color:#16a34a;text-decoration:underline;cursor:pointer;font-size:0.72rem;padding:0">📦 Internal Demand</button> Tab.</div>
|
||||
@ -6863,7 +6916,8 @@ var procAiClustersData = [];
|
||||
var procAiClustersMinTx = 0;
|
||||
|
||||
function showProcSection(name) {
|
||||
['signals','abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
|
||||
['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock',
|
||||
'abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
|
||||
var sec = el('proc-section-' + s);
|
||||
var btn = el('proc-btn-' + s);
|
||||
if (sec) sec.style.display = s === name ? '' : 'none';
|
||||
@ -6873,6 +6927,286 @@ function showProcSection(name) {
|
||||
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
|
||||
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
|
||||
if (name === 'marketplace' && !el('proc-marketplace-grid').querySelector('.card')) loadProcMarketplace();
|
||||
if (name === 'reorder-top' && !el('proc-reorder-top-list').querySelector('div.card,table')) loadReorderTop();
|
||||
if (name === 'arbitrage' && !el('proc-arbitrage-list').querySelector('table')) loadArbitrage();
|
||||
if (name === 'switch-compat' && !el('proc-switch-stats').innerHTML) loadSwitchCompatStats();
|
||||
if (name === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze();
|
||||
if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival();
|
||||
}
|
||||
|
||||
/* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */
|
||||
async function loadReorderTop() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var ff = (el('reorder-ff-filter') || {}).value || '';
|
||||
var listEl = el('proc-reorder-top-list');
|
||||
var summEl = el('proc-reorder-top-summary');
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Loading…</div>';
|
||||
try {
|
||||
var r = await fetch('/api/procurement/reorder-top?limit=60&form_factor=' + encodeURIComponent(ff), { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || 'err');
|
||||
// Summary cards
|
||||
var sm = d.summary || {};
|
||||
if (summEl) summEl.innerHTML = [
|
||||
{ label: '🟢 Buy Now', val: (sm.buy_now||0).toLocaleString(), color: '#22c55e' },
|
||||
{ label: '⏳ Wait', val: (sm.wait||0).toLocaleString(), color: '#f59e0b' },
|
||||
{ label: '⏸ Hold', val: (sm.hold||0).toLocaleString(), color: '#64748b' },
|
||||
{ label: '👁 Monitor', val: (sm.monitor||0).toLocaleString(), color: '#94a3b8' },
|
||||
{ label: 'Ø Buy Strength', val: sm.avg_buy_strength ? Math.round(parseFloat(sm.avg_buy_strength)*100)+'%' : '—', color: '#22c55e' },
|
||||
].map(function(c) {
|
||||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
// Table
|
||||
if (!d.data || !d.data.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Buy-Now-Signale gefunden.</div>'; return; }
|
||||
var rows = d.data.map(function(r) {
|
||||
var str = Math.round(parseFloat(r.signal_strength)*100);
|
||||
var strColor = str >= 70 ? '#22c55e' : str >= 50 ? '#f59e0b' : '#94a3b8';
|
||||
var reasons = Array.isArray(r.reasons) ? r.reasons.join(' · ') : (r.reasons || '—');
|
||||
var pt = r.price_trend === 'rising' ? '📈' : r.price_trend === 'falling' ? '📉' : '→';
|
||||
var st = r.stock_trend === 'declining' ? '📉' : r.stock_trend === 'increasing' ? '📈' : '→';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;color:var(--text-bright);font-size:0.78rem">'+esc(r.vendor_name||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.72rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(r.form_factor||'')+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.78rem">'+esc(String(r.speed_gbps||''))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center;font-weight:700;color:'+strColor+';font-family:monospace">'+str+'%</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem">'+pt+' Preis · '+st+' Stock</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+esc(reasons)+'">'+esc(reasons.substring(0,80))+'</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:center;color:var(--text-dim)">Stärke</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Trends</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Grund</th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
} catch(e) {
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
function reloadReorderTop() { loadReorderTop(); }
|
||||
|
||||
/* ── A: Arbitrage ──────────────────────────────────────────────────────── */
|
||||
async function loadArbitrage() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var listEl = el('proc-arbitrage-list');
|
||||
var statsEl = el('proc-arbitrage-stats');
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Berechne Preisvergleiche aus 63k Equivalenz-Paaren…</div>';
|
||||
try {
|
||||
var r = await fetch('/api/procurement/arbitrage', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || 'err');
|
||||
var st = d.stats || {};
|
||||
if (statsEl) statsEl.innerHTML = [
|
||||
{ label: 'Paare mit Preisdaten', val: (st.totalPairs||0).toLocaleString(), color: '#3b82f6' },
|
||||
{ label: 'FX günstiger', val: (st.fxCheaper||0).toLocaleString(), color: '#22c55e' },
|
||||
{ label: 'Ø Ersparnis', val: (st.avgSavingsPct||0)+'%', color: '#22c55e' },
|
||||
].map(function(c) {
|
||||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||||
}).join('');
|
||||
if (!d.pairs || !d.pairs.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Arbitrage-Paare gefunden.</div>'; return; }
|
||||
var rows = d.pairs.map(function(p) {
|
||||
var savColor = p.savingsPct >= 50 ? '#22c55e' : p.savingsPct >= 20 ? '#f59e0b' : '#94a3b8';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem;font-weight:600;color:var(--text-bright)">'+esc(p.fx_vendor)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(p.fx_part||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem;color:var(--text-dim)">'+esc(p.comp_vendor)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(p.comp_part||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 5px;font-size:0.68rem">'+esc(p.form_factor)+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--text-dim);font-size:0.72rem">$'+esc(String(p.fxUSD))+' <span style="color:var(--text-dim);font-size:0.65rem">('+esc(p.fx_curr)+')</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--text-dim);font-size:0.72rem">$'+esc(String(p.compUSD))+' <span style="color:var(--text-dim);font-size:0.65rem">('+esc(p.comp_curr)+')</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:800;color:'+savColor+';font-family:monospace">'+p.savingsPct+'%</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.72rem"><thead><tr style="background:var(--surface2)">'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">FX Vendor</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">FX Part</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Competitor</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Comp Part</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">FX (USD)</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Comp (USD)</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Günstiger</th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
} catch(e) {
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── B: Switch Compatibility ───────────────────────────────────────────── */
|
||||
async function loadSwitchCompatStats() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var statsEl = el('proc-switch-stats');
|
||||
try {
|
||||
var r = await fetch('/api/procurement/switch-compat', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
var st = d.stats || {};
|
||||
if (statsEl) statsEl.innerHTML = '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1rem">'
|
||||
+ [
|
||||
{ label: 'Switches in DB', val: (st.total_switches||0).toLocaleString(), color: '#6366f1' },
|
||||
{ label: 'Kompatible Transceiver', val: (st.total_transceivers||0).toLocaleString(), color: '#3b82f6' },
|
||||
{ label: 'Compat-Einträge', val: (st.total_compat_rows||0).toLocaleString(), color: '#22c55e' },
|
||||
].map(function(c) {
|
||||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||||
}).join('') + '</div>'
|
||||
+ '<div style="font-size:0.72rem;color:var(--text-dim)">Top Switches nach Compat-Anzahl: '
|
||||
+ (d.topSwitches||[]).slice(0,6).map(function(s) {
|
||||
return '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:2px 7px;margin:0 3px;font-size:0.7rem">'+esc(s.sw_vendor||s.vendor||'')+ ' '+esc(s.sw_model||s.model||'')+'</span>';
|
||||
}).join('') + '</div>';
|
||||
} catch(e) {}
|
||||
}
|
||||
async function loadSwitchCompat() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var query = (el('switch-search-input') || {}).value || '';
|
||||
var resultsEl = el('proc-switch-results');
|
||||
if (!query.trim()) { if (resultsEl) resultsEl.innerHTML = '<div style="color:var(--text-dim)">Switch-Modell eingeben.</div>'; return; }
|
||||
if (resultsEl) resultsEl.innerHTML = '<div style="color:var(--text-dim)">Suche…</div>';
|
||||
try {
|
||||
var r = await fetch('/api/procurement/switch-compat?search='+encodeURIComponent(query)+'&limit=20', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
var switches = d.switches || [];
|
||||
var txList = d.transceivers || [];
|
||||
if (!switches.length) { resultsEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Kein Switch gefunden für "'+esc(query)+'".</div>'; return; }
|
||||
var html = switches.map(function(sw) {
|
||||
var txForSw = txList.filter(function(t) { return t.switch_id === sw.id; });
|
||||
var txRows = txForSw.map(function(t) {
|
||||
var priceStr = t.min_price ? t.currency+' '+t.min_price : '—';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.4rem 0.5rem;font-weight:600;font-size:0.75rem;color:var(--text-bright)">'+esc(t.vendor_name)+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;font-family:monospace;font-size:0.7rem;color:var(--text-dim)">'+esc(t.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:1px 5px;font-size:0.68rem">'+esc(t.form_factor)+'</span></td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(t.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--green);font-size:0.72rem">'+esc(priceStr)+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:center;font-size:0.68rem;color:var(--text-dim)">'+esc(t.verification_method||'')+'</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1.25rem">'
|
||||
+ '<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">'
|
||||
+ '<span style="font-weight:800;font-size:0.9rem;color:var(--text-bright)">'+esc((sw.sw_vendor||sw.vendor||'')+ ' '+( sw.sw_model||sw.model||''))+'</span>'
|
||||
+ (sw.sw_series||sw.series ? '<span style="font-size:0.72rem;color:var(--text-dim)">'+esc(sw.sw_series||sw.series)+'</span>' : '')
|
||||
+ '<span style="margin-left:auto;font-size:0.72rem;background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 8px">'+esc(String(sw.compat_count||txForSw.length))+' kompatibel</span>'
|
||||
+ '</div>'
|
||||
+ (txForSw.length ? '<table style="width:100%;border-collapse:collapse;font-size:0.72rem"><thead><tr style="background:var(--surface2)"><th style="padding:0.35rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th><th style="padding:0.35rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th><th style="padding:0.35rem 0.5rem;color:var(--text-dim)">FF</th><th style="padding:0.35rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th><th style="padding:0.35rem 0.5rem;text-align:right;color:var(--text-dim)">Preis</th><th style="padding:0.35rem 0.5rem;color:var(--text-dim)">Methode</th></tr></thead><tbody>'
|
||||
+ txRows + '</tbody></table>'
|
||||
: '<div style="color:var(--text-dim);font-size:0.75rem">Keine Transceiver-Preise verfügbar.</div>')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
resultsEl.innerHTML = html;
|
||||
} catch(e) {
|
||||
resultsEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── C: Supply Squeeze Detector ────────────────────────────────────────── */
|
||||
async function loadSupplySqueeze() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var summEl = el('proc-squeeze-summary');
|
||||
var listEl = el('proc-squeeze-list');
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Analysiere Preis- & Nachfragesignale…</div>';
|
||||
try {
|
||||
var r = await fetch('/api/procurement/supply-squeeze', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
var sigs = d.signals || [];
|
||||
var crit = sigs.filter(function(s) { return s.severity === 'critical'; });
|
||||
var warn = sigs.filter(function(s) { return s.severity === 'warning'; });
|
||||
if (summEl) summEl.innerHTML = '<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem">'
|
||||
+ (crit.length ? '<div style="padding:0.5rem 1rem;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#ef4444">🔴 '+crit.length+' Kritisch</div>' : '')
|
||||
+ (warn.length ? '<div style="padding:0.5rem 1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#f59e0b">⚠️ '+warn.length+' Warnung</div>' : '')
|
||||
+ (!crit.length && !warn.length ? '<div style="padding:0.5rem 1rem;background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#22c55e">✅ Kein akuter Engpass erkannt</div>' : '')
|
||||
+ '</div>';
|
||||
if (!sigs.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Squeeze-Signale — Markt stabil.</div>'; return; }
|
||||
var sevColor = { critical: '#ef4444', warning: '#f59e0b', watch: '#f59e0b', ok: '#64748b' };
|
||||
var sevIcon = { critical: '🔴', warning: '⚠️', watch: '👁', ok: '✅' };
|
||||
var rows = sigs.slice(0,30).map(function(s) {
|
||||
var col = sevColor[s.severity] || '#64748b';
|
||||
var icon = sevIcon[s.severity] || '';
|
||||
var mom = s.price_momentum_pct !== 0 ? (s.price_momentum_pct > 0 ? '+' : '') + s.price_momentum_pct + '%' : '—';
|
||||
var momColor = s.price_momentum_pct > 10 ? '#ef4444' : s.price_momentum_pct > 0 ? '#f59e0b' : '#22c55e';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.8rem;color:'+col+'">'+icon+' '+esc(String(s.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(s.form_factor||'—')+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+momColor+';font-family:monospace">'+esc(mom)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim)">'+esc((s.hype_phase||'—').replace(/_/g,' '))+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-size:0.72rem">'+(s.ai_demand_tx ? s.ai_demand_tx.toLocaleString()+' tx' : '—')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center;font-weight:800;color:'+col+'">'+esc(String(s.activeSignals))+'/4</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim);max-width:200px">'+esc((s.reasons||[]).join(' · ').substring(0,100))+'</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Technologie</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Form Factor</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Preis 30d</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Hype Phase</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">AI Demand</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:center;color:var(--text-dim)">Signale</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Gründe</th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
} catch(e) {
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── D: Dead Stock Revival ─────────────────────────────────────────────── */
|
||||
async function loadDeadStockRevival() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var summEl = el('proc-deadstock-summary');
|
||||
var listEl = el('proc-deadstock-list');
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Analyse Dead-Stock gegen Hype-Cycle…</div>';
|
||||
try {
|
||||
var r = await fetch('/api/procurement/dead-stock-revival', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
if (summEl) summEl.innerHTML = [
|
||||
{ label: 'Total Dead Stock', val: (d.totalDeadStock||0).toLocaleString(), color: '#64748b' },
|
||||
{ label: 'Revival Kandidaten', val: (d.revivalCount||0).toLocaleString(), color: '#f59e0b' },
|
||||
{ label: 'Revival Rate', val: d.totalDeadStock ? Math.round(d.revivalCount/d.totalDeadStock*100)+'%' : '—', color: '#22c55e' },
|
||||
].map(function(c) {
|
||||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||||
}).join('');
|
||||
if (!d.revivals || !d.revivals.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Dead-Stock-Revival-Kandidaten gefunden.</div>'; return; }
|
||||
var PHASE_ICONS = { innovation_trigger:'🔬', peak_inflated_expectations:'🚀', slope_enlightenment:'📈', plateau_productivity:'✅' };
|
||||
var rows = d.revivals.map(function(r) {
|
||||
var icon = PHASE_ICONS[r.hype_phase] || '●';
|
||||
var scoreColor = r.hype_score >= 70 ? '#22c55e' : r.hype_score >= 50 ? '#f59e0b' : '#94a3b8';
|
||||
var trend = r.demand_trend_pct != null ? (parseFloat(r.demand_trend_pct) > 0 ? '+' : '') + Math.round(parseFloat(r.demand_trend_pct)) + '%' : '—';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.75rem;color:var(--text-bright)">'+esc(r.vendor_name||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 5px;font-size:0.68rem">'+esc(r.form_factor)+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(r.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem">'+icon+' '+esc((r.hype_phase||'').replace(/_/g,' '))+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+scoreColor+';font-family:monospace">'+Math.round(r.hype_score)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-size:0.72rem;color:'+(parseFloat(trend)>0?'#22c55e':'#64748b')+'">'+esc(trend)+'</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
listEl.innerHTML = '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.75rem">Dead-Stock-SKUs deren Speed-Klasse eine <strong>steigende Hype-Phase</strong> hat (Hype Score >30). Revival = Markt dreht sich wieder für diese Technologie.</div>'
|
||||
+ '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Hype Phase</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Score</th>'
|
||||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Trend</th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
} catch(e) {
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProcurement() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user