diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index d20733a..3cb77a3 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -1,6 +1,15 @@ # TIP Changelog Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` +{"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."} + +{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab 'πŸ”€ Equivalences' β€” search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products β†’ 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."} +{"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."} +{"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools β€” find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."} +{"d":"2026-05-14","t":"FIX","m":"Blog from URL: SPA-aware content extraction. fetchUrlContent() now extracts OG/meta tags (og:title, og:description, name=description, og:site_name) as fallback for JavaScript SPAs. Returns spaDetected=true when body text < 300 chars. from-url endpoint skips gatherBlogData() product injection when SPA detected β€” prevents fo-blog model from defaulting to optical networking domain on non-networking URLs. additionalContext now includes explicit SPA warning + meta content. generated_by in pipeline UPDATE uses active model name (no more hardcoded 'fo-blog-engine-v7'). Dashboard shows SPA warning toast + spa_detected field in response."} +{"d":"2026-05-14","t":"FEAT","m":"Blog Engine: URL β†’ Blog feature. POST /api/blog/from-url fetches any URL server-side (20s timeout, redirect-follow), strips scripts/nav/footer/SVG, extracts readable text (~5000 chars) + page title, passes as structured additional_context to the 16-step FO blog pipeline. Dashboard: new 'πŸ”— Blog aus URL generieren' panel with URL input (Enter key supported), Blog-Typ selector, loading state, and char count confirmation. Same pollBlogLlm() polling reused for step progress."} +{"d":"2026-05-14","t":"UI","m":"Switch modal Flexoptix section: (1) Speed formatting fixed β€” 1600.00G β†’ 1.6T, 400G clean integer (fmtSpeed() helper, β‰₯1000 Gbps β†’ T). (2) Lagerbestand badges added per transceiver row: DE-Lager (green), Global-Lager (blue), Zulauf with ETA date (yellow). Data sourced from stock_observations via LEFT JOIN LATERAL in getFlexoptixSuggestions(). Badges hidden when quantities are null/0 (scraper not yet populating Flexoptix warehouse columns β€” shows automatically once scraper is extended)."} +{"d":"2026-05-14","t":"FEAT","m":"Stock velocity API: GET /api/stock/velocity (paginated, filterable by vendor_id/confidence/stockout_days/min_sell_rate/part_number) + GET /api/stock/velocity/:id (per-product velocity summary + sell/zulauf event history). Both routes live in packages/api/src/routes/stock.ts, compiled + deployed to tip-api PM2 id 24, port 3201."} {"d":"2026-05-14","t":"DATA","m":"Demo data cleanup: deleted 2133 demo rows from reorder_signals (is_demo_data=true). Stock observation coverage expanded: atgbics.ts + optcore.ts now call upsertStockObservation after each price observation (binary in/out stock, confidence=1). FS.com scraper already runs 3x daily from Mac (02:00/10:00/18:00) with full DE-Lager/Global-Lager/Nachlieferung breakdown. Competitor stock audit: QSFPTEK (confidence=2, real quantities), FS.COM (confidence=3, per-warehouse breakdown) are highest fidelity; ATGBICS/Optcore added at confidence=1 (binary); sfpcables/prolabs/wiitek hardcode or lack stock β€” not added."} {"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 β†’ fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). Persisted /opt/tip/blog-llm-settings.json overrode env β€” also updated. tip-api restarted, PM2 state saved."} {"d":"2026-05-13","t":"FEAT","m":"BlogLLM auto-discovery: client.ts now probes Ollama at startup + every 10 min, reconciles configured fo-blog-vN against actual available tags, auto-falls to highest available version when configured model no longer exists. Magatama-aware sort: base 'fo-blog-vN' tag wins over '-rM' revisions within same N (matches Magatama adoption convention where -rM is intermediate adapter save, base is production alias). New POST /api/blog/llm/refresh-discovery endpoint for manual trigger. Eliminates 3-step manual sync after every Magatama training."} diff --git a/packages/api/src/routes/procurement.ts b/packages/api/src/routes/procurement.ts index 0f03c9a..bb028ad 100644 --- a/packages/api/src/routes/procurement.ts +++ b/packages/api/src/routes/procurement.ts @@ -9,6 +9,8 @@ * GET /api/procurement/market-intel β€” Market intelligence events * GET /api/procurement/stock-trends/:id β€” Stock history for a transceiver * GET /api/procurement/lifecycle β€” Lifecycle events (EOL, standards) + * GET /api/procurement/ai-clusters β€” AI datacenter announcements with transceiver demand + * GET /api/procurement/internal-demand β€” Flexoptix internal demand velocity (fast/slow/dead) */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; @@ -291,3 +293,134 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => { res.status(500).json({ error: "Internal server error" }); } }); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/ai-clusters?days=90&limit=50&min_transceivers=0 +// Returns AI datacenter announcements with transceiver demand estimates +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/ai-clusters", async (req: Request, res: Response) => { + try { + const { + days = "90", + limit = "50", + min_transceivers = "0", + } = req.query; + + const daysN = Math.min(Math.max(parseInt(days as string) || 90, 1), 730); + const limitN = Math.min(parseInt(limit as string) || 50, 200); + const minTx = parseInt(min_transceivers as string) || 0; + + const result = await pool.query( + `SELECT + id, company, title, summary, + announced_date, scale_mw, scale_servers, + network_speed, estimated_transceivers, + deployment_date, location, source_url, source_name, + created_at + FROM ai_cluster_announcements + WHERE + (announced_date IS NULL OR announced_date >= NOW() - INTERVAL '1 day' * $1) + AND ($2 = 0 OR estimated_transceivers >= $2) + ORDER BY announced_date DESC NULLS LAST, created_at DESC + LIMIT $3`, + [daysN, minTx, limitN] + ); + + // Aggregate stats + const statsResult = await pool.query( + `SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates, + SUM(estimated_transceivers) FILTER (WHERE estimated_transceivers > 0) AS total_estimated_transceivers, + SUM(scale_mw) FILTER (WHERE scale_mw IS NOT NULL) AS total_mw, + COUNT(DISTINCT company) FILTER (WHERE company != 'Unknown') AS distinct_companies + FROM ai_cluster_announcements + WHERE announced_date >= NOW() - INTERVAL '1 day' * $1`, + [daysN] + ); + + res.json({ + data: result.rows, + stats: statsResult.rows[0], + period_days: daysN, + }); + } catch (err) { + console.error("AI clusters error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/internal-demand?velocity_class=fast_mover&limit=100 +// Returns Flexoptix internal demand data β€” real SKU velocity from internal data +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/internal-demand", async (req: Request, res: Response) => { + try { + const { + velocity_class, + limit = "100", + offset = "0", + sort = "demand_12m", + } = req.query; + + const allowedSorts: Record = { + demand_12m: "fid.demand_12m DESC", + demand_3m: "fid.demand_3m DESC", + trend: "fid.demand_trend_pct DESC NULLS LAST", + sku: "fid.sku ASC", + }; + const orderBy = allowedSorts[sort as string] ?? allowedSorts["demand_12m"]; + + const params: unknown[] = []; + const conditions: string[] = ["fid.is_internal = true"]; + let idx = 1; + + if (velocity_class) { + conditions.push(`fid.velocity_class = $${idx}`); + params.push(velocity_class); + idx++; + } + + params.push(Math.min(parseInt(limit as string) || 100, 500)); + params.push(Math.max(parseInt(offset as string) || 0, 0)); + + const result = await pool.query( + `SELECT + fid.id, fid.sku, fid.description, + fid.demand_12m, fid.demand_3m, fid.demand_trend_pct, + fid.velocity_class, fid.imported_at, + t.part_number, t.standard_name, t.form_factor, t.speed_gbps, + t.reach_label, t.image_url, + v.name AS vendor_name + FROM flexoptix_internal_demand fid + LEFT JOIN transceivers t ON t.id = fid.transceiver_id + LEFT JOIN vendors v ON v.id = t.vendor_id + WHERE ${conditions.join(" AND ")} + ORDER BY ${orderBy} + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + // Velocity summary + const summaryResult = await pool.query( + `SELECT + velocity_class, + COUNT(*) AS cnt, + SUM(demand_12m)::numeric(12,0) AS total_demand_12m, + AVG(demand_trend_pct)::numeric(8,1) AS avg_trend_pct + FROM flexoptix_internal_demand + WHERE is_internal = true + GROUP BY velocity_class + ORDER BY total_demand_12m DESC NULLS LAST` + ); + + res.json({ + data: result.rows, + summary: summaryResult.rows, + total: result.rowCount, + }); + } catch (err) { + console.error("Internal demand error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index fd8d225..8fba3f8 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1566,6 +1566,8 @@
+ +
@@ -1574,7 +1576,7 @@
-
⚠ DEMO DATA β€” Reorder Signals basieren auf synthetischen Verkaufs- und Lagermengen. SignalstΓ€rke spiegelt keine echten Flexoptix-BestΓ€nde wider.
+
β„Ή Reorder Signals basieren auf ABC-Klassifizierung + Preis-Observations-Frequenz. Echte Verkaufsmengendaten β†’ Tab.
@@ -1589,7 +1591,7 @@ + + + + + +
@@ -6710,14 +6762,22 @@ var procCurrentSignalFilter = ''; var procCurrentAbcFilter = ''; var procSignalsData = []; var procAbcData = []; +var procDemandData = []; +var procDemandSummary = []; +var procDemandFilter = ''; +var procAiClustersData = []; +var procAiClustersMinTx = 0; function showProcSection(name) { - ['signals','abc','market','lifecycle'].forEach(function(s) { + ['signals','abc','demand','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'; if (btn) { btn.classList.toggle('proc-btn-active', s === name); } }); + // Lazy-load on first visit + if (name === 'demand' && procDemandData.length === 0) loadInternalDemand(); + if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters(); } async function loadProcurement() { @@ -6933,6 +6993,157 @@ async function loadProcLifecycle() { } } +// ─── INTERNAL DEMAND ───────────────────────────────────────────────────────── + +async function loadInternalDemand() { + try { + var d = await api('/api/procurement/internal-demand?limit=500'); + procDemandData = d.data || []; + procDemandSummary = d.summary || []; + renderDemandSummaryCards(); + filterVelocity(procDemandFilter); + } catch(e) { + el('demand-tbody').innerHTML = 'Could not load demand data.'; + } +} + +function renderDemandSummaryCards() { + var velConfig = { + fast_mover: { label:'Fast Movers', icon:'πŸš€', bg:'rgba(22,163,74,0.08)', border:'rgba(22,163,74,0.25)', color:'#16a34a' }, + regular: { label:'Regular', icon:'πŸ“¦', bg:'rgba(37,99,235,0.08)', border:'rgba(37,99,235,0.25)', color:'#2563eb' }, + slow_mover: { label:'Slow Movers', icon:'🐒', bg:'rgba(245,158,11,0.08)', border:'rgba(245,158,11,0.25)', color:'#c07000' }, + dead_stock: { label:'Dead Stock', icon:'πŸ’€', bg:'rgba(136,136,136,0.1)', border:'rgba(136,136,136,0.25)','color':'var(--text-dim)' }, + }; + var cards = procDemandSummary.map(function(row) { + var cfg = velConfig[row.velocity_class] || { label:row.velocity_class, icon:'πŸ“Š', bg:'var(--surface2)', border:'var(--border)', color:'var(--text)' }; + var trend = parseFloat(row.avg_trend_pct); + var trendHtml = isNaN(trend) ? '' : '' + (trend >= 0 ? 'β–²' : 'β–Ό') + ' ' + Math.abs(trend).toFixed(1) + '%'; + return '
' + + '
' + cfg.icon + '
' + + '
' + cfg.label + '
' + + '
' + parseInt(row.cnt).toLocaleString() + '
' + + '
SKUs
' + + (parseFloat(row.total_demand_12m) > 0 ? '
' + parseInt(row.total_demand_12m).toLocaleString() + ' units/12M
' : '') + + (trendHtml ? '
' + trendHtml + '
' : '') + + '
'; + }); + el('demand-summary-cards').innerHTML = cards.join(''); +} + +function filterVelocity(cls) { + procDemandFilter = cls; + var data = cls ? procDemandData.filter(function(r) { return r.velocity_class === cls; }) : procDemandData; + var velColors = { fast_mover:'#16a34a', regular:'#2563eb', slow_mover:'#c07000', dead_stock:'var(--text-dim)' }; + var velIcons = { fast_mover:'πŸš€', regular:'πŸ“¦', slow_mover:'🐒', dead_stock:'πŸ’€' }; + el('demand-tbody').innerHTML = data.slice(0, 300).map(function(r) { + var trend = parseFloat(r.demand_trend_pct); + var trendHtml = isNaN(trend) ? 'β€”' : '' + (trend >= 0 ? '+' : '') + trend.toFixed(1) + '%'; + var velColor = velColors[r.velocity_class] || 'var(--text-dim)'; + var velIcon = velIcons[r.velocity_class] || 'Β·'; + var productName = r.standard_name || r.part_number || r.description || r.sku; + var demand12 = parseFloat(r.demand_12m); + var demand3 = parseFloat(r.demand_3m); + return '' + + '' + velIcon + '' + + '
' + esc(productName || 'β€”') + '
' + + '
' + esc(r.sku) + (r.vendor_name ? ' Β· ' + esc(r.vendor_name) : '') + '
' + + '' + esc(r.form_factor || 'β€”') + (r.speed_gbps ? ' ' + r.speed_gbps + 'G' : '') + '' + + '' + (demand12 > 0 ? demand12.toLocaleString(undefined,{maximumFractionDigits:0}) : 'β€”') + '' + + '' + (demand3 > 0 ? demand3.toLocaleString(undefined,{maximumFractionDigits:0}) : 'β€”') + '' + + '' + trendHtml + '' + + ''; + }).join('') || 'No items for this filter.'; +} + +// ─── AI CLUSTERS ───────────────────────────────────────────────────────────── + +async function loadAiClusters() { + var days = (el('ai-days-select') || {}).value || '90'; + var container = el('ai-cluster-grid'); + container.innerHTML = '
Loading AI cluster data...
'; + el('ai-cluster-stats').innerHTML = '
Computing stats...
'; + try { + var d = await api('/api/procurement/ai-clusters?days=' + days + '&limit=100'); + procAiClustersData = d.data || []; + var stats = d.stats || {}; + renderAiClusterStats(stats, d.period_days || parseInt(days)); + filterAiClusters(procAiClustersMinTx); + } catch(e) { + container.innerHTML = '
Could not load AI cluster announcements.
'; + } +} + +function reloadAiClusters() { + procAiClustersData = []; + loadAiClusters(); +} + +function renderAiClusterStats(stats, days) { + var totalTx = parseInt(stats.total_estimated_transceivers) || 0; + var totalMW = parseFloat(stats.total_mw) || 0; + var withEst = parseInt(stats.with_estimates) || 0; + var total = parseInt(stats.total) || 0; + var companies = parseInt(stats.distinct_companies) || 0; + el('ai-cluster-stats').innerHTML = [ + statCard('πŸ“°', total.toLocaleString(), 'Announcements', 'Last ' + days + ' days'), + statCard('🏭', companies.toLocaleString(), 'Named Companies', 'Distinct organizations'), + statCard('⚑', totalMW > 0 ? totalMW.toLocaleString(undefined,{maximumFractionDigits:0}) + ' MW' : 'β€”', 'Total Power (known)', 'Sum of MW for entries with scale data'), + statCard('πŸ“‘', totalTx > 0 ? totalTx.toLocaleString() : 'β€”', 'Est. Transceivers', withEst + ' entries with demand estimates'), + ].join(''); +} + +function statCard(icon, value, label, sub) { + return '
' + + '
' + icon + '
' + + '
' + value + '
' + + '
' + label + '
' + + '
' + sub + '
' + + '
'; +} + +function filterAiClusters(minTx) { + procAiClustersMinTx = minTx; + var data = minTx > 0 ? procAiClustersData.filter(function(r) { return (r.estimated_transceivers || 0) >= minTx; }) : procAiClustersData; + if (!data.length) { + el('ai-cluster-grid').innerHTML = '
No matching announcements.
'; + return; + } + el('ai-cluster-grid').innerHTML = data.map(function(item) { + var txCount = parseInt(item.estimated_transceivers); + var mw = parseFloat(item.scale_mw); + var hasTxEst = !isNaN(txCount) && txCount > 0; + var hasMw = !isNaN(mw) && mw > 0; + var dateStr = item.announced_date ? new Date(item.announced_date).toLocaleDateString('de-DE') : ''; + var company = item.company && item.company !== 'Unknown' ? esc(item.company) : ''; + var loc = item.location ? esc(item.location) : ''; + var borderColor = hasTxEst ? '#7c5cfc' : (hasMw ? '#2563eb' : 'var(--border)'); + var txHtml = hasTxEst + ? '
πŸ“‘ ~' + txCount.toLocaleString() + ' transceivers
' + : ''; + var mwHtml = hasMw + ? '⚑ ' + mw.toLocaleString() + ' MW' + : ''; + var speedHtml = item.network_speed + ? '' + esc(item.network_speed) + '' + : ''; + return '
' + + '
' + + '
' + + (txHtml ? txHtml + ' ' : '') + + '
' + mwHtml + speedHtml + '
' + + '
' + + (dateStr ? '' + dateStr + '' : '') + + '
' + + '
' + esc(item.title) + '
' + + (item.summary ? '
' + esc(item.summary.substring(0,200)) + (item.summary.length > 200 ? '…' : '') + '
' : '') + + '
' + + '' + (company || '?') + (loc ? ' Β· ' + loc : '') + (item.source_name ? ' Β· ' + esc(item.source_name) : '') + '' + + (item.source_url ? 'β†’ Source' : '') + + '
' + + '
'; + }).join(''); +} + // INIT loadOverview(); loadChangelog();