transceiver-db/sql/013-v020-sales-intelligence.sql
Rene Fichtmueller a69acc4588 feat(v0.2.0): Sales Intelligence Engine — Phase 0+A
New API routes:
- GET /api/finder — Switch→Flexoptix transceiver finder with FlexBox coding
- GET /api/competitor-alerts — Competitor intelligence (price changes, new products, stock)
- GET /api/forecast/:technology — Sales forecast 3/9/12/18 months + buy/wait/hold signal
- POST /api/transport/plan — Transport system planner (city→city BOM with fiber providers)

New MCP tools:
- find_flexoptix_for_switch — Customer switch → Flexoptix products
- get_competitor_alerts — Competitor monitoring
- plan_transport — Network transport planning
- forecast_sales — Volume/revenue prediction
- generate_blog — Enhanced blog generation

New DB tables (migration 013):
- competitor_alerts, price_changes, flexoptix_product_map
- sales_forecasts, fiber_providers, fiber_routes, cities
- generated_datasheets, blog_series
- Views: v_price_coverage, v_image_coverage, v_switch_flexoptix_finder

Seed data (migration 014):
- 25 European cities with IX/DC locations + coordinates
- 15 fiber providers (euNetworks, Telia, DTAG, Colt, Zayo, etc.)
- 16 fiber routes with pricing (Germany focus)

Infrastructure:
- Scraper scheduler: 2h Flexoptix, 4h FS.com/Optcore (was 6-8h)
- Change detector for competitor price/stock monitoring
- Image downloader utility with coverage tracking
2026-03-31 08:51:22 +02:00

348 lines
13 KiB
SQL

-- Migration 013: v0.2.0 Sales Intelligence Engine
-- Adds: competitor_alerts, price_changes, image tracking, finder views, blog_posts_v2, forecast tables
-- ============================================================
-- IMAGE TRACKING (WS0)
-- ============================================================
-- Add image columns if not exist
DO $$ BEGIN
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_url TEXT;
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_r2_key TEXT;
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_thumb_r2_key TEXT;
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_scraped_at TIMESTAMPTZ;
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS has_image BOOLEAN DEFAULT FALSE;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE switches ADD COLUMN IF NOT EXISTS image_thumb_r2_key TEXT;
ALTER TABLE switches ADD COLUMN IF NOT EXISTS has_image BOOLEAN DEFAULT FALSE;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_transceivers_has_image ON transceivers(has_image) WHERE has_image = false;
-- ============================================================
-- PRICE COVERAGE (WS0b)
-- ============================================================
-- View: products missing recent prices
CREATE OR REPLACE VIEW v_price_coverage AS
SELECT
t.id,
t.slug,
t.form_factor,
t.speed_gbps,
t.reach_label,
v.name AS vendor_name,
(SELECT MAX(po.time) FROM price_observations po WHERE po.transceiver_id = t.id) AS last_price_at,
(SELECT COUNT(*) FROM price_observations po WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days') AS recent_price_count,
CASE
WHEN (SELECT COUNT(*) FROM price_observations po WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days') > 0 THEN TRUE
ELSE FALSE
END AS has_recent_price
FROM transceivers t
LEFT JOIN vendors v ON t.vendor_id = v.id
ORDER BY has_recent_price ASC, t.speed_gbps DESC;
-- View: image coverage
CREATE OR REPLACE VIEW v_image_coverage AS
SELECT
t.id,
t.slug,
t.form_factor,
t.speed_gbps,
t.image_url,
t.image_r2_key,
t.has_image,
v.name AS vendor_name
FROM transceivers t
LEFT JOIN vendors v ON t.vendor_id = v.id
ORDER BY t.has_image ASC, t.speed_gbps DESC;
-- ============================================================
-- COMPETITOR INTELLIGENCE (WS4)
-- ============================================================
CREATE TABLE IF NOT EXISTS competitor_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vendor_id UUID REFERENCES vendors(id),
transceiver_id UUID REFERENCES transceivers(id),
alert_type TEXT NOT NULL CHECK (alert_type IN (
'new_product', 'price_drop', 'price_increase', 'out_of_stock',
'back_in_stock', 'discontinued', 'new_vendor'
)),
severity TEXT DEFAULT 'info' CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
-- Price change details
old_price NUMERIC,
new_price NUMERIC,
price_delta NUMERIC, -- absolute change
price_pct NUMERIC, -- percentage change
currency TEXT DEFAULT 'USD',
-- Product details
part_number TEXT,
product_name TEXT,
form_factor TEXT,
speed_gbps NUMERIC,
source_url TEXT,
-- Status
acknowledged BOOLEAN DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_type ON competitor_alerts(alert_type);
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_vendor ON competitor_alerts(vendor_id);
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_created ON competitor_alerts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_unack ON competitor_alerts(acknowledged) WHERE acknowledged = FALSE;
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_severity ON competitor_alerts(severity);
-- Price change history (deduplicated, one row per actual change)
CREATE TABLE IF NOT EXISTS price_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transceiver_id UUID REFERENCES transceivers(id),
vendor_id UUID REFERENCES vendors(id),
old_price NUMERIC NOT NULL,
new_price NUMERIC NOT NULL,
delta NUMERIC NOT NULL, -- new - old
delta_pct NUMERIC NOT NULL, -- ((new-old)/old) * 100
currency TEXT DEFAULT 'USD',
detected_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_price_changes_transceiver ON price_changes(transceiver_id, detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_price_changes_vendor ON price_changes(vendor_id, detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_price_changes_detected ON price_changes(detected_at DESC);
-- ============================================================
-- FINDER: FLEXOPTIX PRODUCT MAPPING (WS1)
-- ============================================================
-- Map OEM part numbers to Flexoptix products
CREATE TABLE IF NOT EXISTS flexoptix_product_map (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
oem_part_number TEXT NOT NULL,
oem_vendor TEXT NOT NULL,
flexoptix_sku TEXT,
flexoptix_url TEXT,
flexoptix_price_eur NUMERIC,
form_factor TEXT,
speed_gbps NUMERIC,
reach_label TEXT,
fiber_type TEXT,
match_type TEXT DEFAULT 'exact' CHECK (match_type IN ('exact', 'equivalent', 'compatible', 'suggested')),
last_verified TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(oem_part_number, oem_vendor)
);
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_oem ON flexoptix_product_map(oem_part_number);
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_vendor ON flexoptix_product_map(oem_vendor);
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_ff ON flexoptix_product_map(form_factor, speed_gbps);
-- Finder view: switch → compatible Flexoptix products
CREATE OR REPLACE VIEW v_switch_flexoptix_finder AS
SELECT
sw.id AS switch_id,
sw.model AS switch_model,
sw.series AS switch_series,
sv.name AS switch_vendor,
c.status AS compat_status,
c.firmware_min,
c.notes AS compat_notes,
t.id AS transceiver_id,
t.slug AS transceiver_slug,
t.form_factor,
t.speed_gbps,
t.reach_label,
t.fiber_type,
t.wavelengths,
t.connector,
t.image_url AS transceiver_image,
fpm.flexoptix_sku,
fpm.flexoptix_url,
fpm.flexoptix_price_eur,
fpm.match_type,
(SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_price,
(SELECT po.currency FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_currency
FROM switches sw
JOIN vendors sv ON sw.vendor_id = sv.id
JOIN compatibility c ON c.switch_id = sw.id AND c.status = 'compatible'
JOIN transceivers t ON c.transceiver_id = t.id
LEFT JOIN flexoptix_product_map fpm ON (
fpm.form_factor = t.form_factor
AND fpm.speed_gbps = t.speed_gbps
AND fpm.reach_label = t.reach_label
);
-- ============================================================
-- BLOG ENGINE v2 (WS8)
-- ============================================================
CREATE TABLE IF NOT EXISTS blog_series (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
total_parts INTEGER DEFAULT 1,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'paused')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add v2 columns to existing blog_drafts if they exist
DO $$ BEGIN
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES blog_series(id);
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS series_part INTEGER;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_title TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_description TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_slug TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_focus_keyword TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_score INTEGER;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS readability_score NUMERIC;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS hero_image_url TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS hero_image_r2_key TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS related_products UUID[];
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS related_switches UUID[];
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS competitor_data JSONB;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS pricing_data JSONB;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS export_markdown TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS export_html TEXT;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ;
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
-- ============================================================
-- SALES FORECAST (WS5)
-- ============================================================
CREATE TABLE IF NOT EXISTS sales_forecasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technology TEXT NOT NULL, -- "400G QSFP-DD", "100G QSFP28", etc.
speed_gbps NUMERIC,
form_factor TEXT,
-- Forecast periods
forecast_3m_units INTEGER,
forecast_3m_revenue NUMERIC,
forecast_9m_units INTEGER,
forecast_9m_revenue NUMERIC,
forecast_12m_units INTEGER,
forecast_12m_revenue NUMERIC,
forecast_18m_units INTEGER,
forecast_18m_revenue NUMERIC,
-- Price trajectory
current_asp NUMERIC,
asp_3m NUMERIC,
asp_12m NUMERIC,
price_floor NUMERIC,
months_to_floor INTEGER,
-- Confidence
confidence_3m NUMERIC,
confidence_9m NUMERIC,
confidence_12m NUMERIC,
confidence_18m NUMERIC,
-- Buy signal
buy_signal TEXT CHECK (buy_signal IN ('BUY_NOW', 'WAIT', 'HOLD')),
signal_reason TEXT,
-- Model info
model_version TEXT DEFAULT 'norton-bass-v1',
data_points INTEGER, -- how many price observations used
computed_at TIMESTAMPTZ DEFAULT NOW(),
valid_until TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
);
CREATE INDEX IF NOT EXISTS idx_forecasts_tech ON sales_forecasts(technology);
CREATE INDEX IF NOT EXISTS idx_forecasts_computed ON sales_forecasts(computed_at DESC);
-- ============================================================
-- TRANSPORT PLANNER (WS3)
-- ============================================================
CREATE TABLE IF NOT EXISTS fiber_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
website TEXT,
type TEXT CHECK (type IN ('tier1', 'tier2', 'regional', 'municipal', 'hyperscaler')),
headquarters TEXT,
coverage_countries TEXT[],
products TEXT[], -- 'dark_fiber', 'wavelength', 'ip_transit', 'ethernet'
peering_ixs TEXT[], -- IX names where they peer
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fiber_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID REFERENCES fiber_providers(id),
city_a TEXT NOT NULL,
city_b TEXT NOT NULL,
country TEXT DEFAULT 'DE',
distance_km NUMERIC,
fiber_distance_km NUMERIC, -- actual fiber route (usually 1.3-1.5x straight line)
product_type TEXT, -- 'dark_fiber', 'wavelength_100g', 'wavelength_400g', etc.
monthly_price_eur NUMERIC,
setup_fee_eur NUMERIC,
min_contract_months INTEGER,
latency_ms NUMERIC,
available BOOLEAN DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(provider_id, city_a, city_b, product_type)
);
CREATE TABLE IF NOT EXISTS cities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
country TEXT NOT NULL DEFAULT 'DE',
lat NUMERIC,
lon NUMERIC,
has_ix BOOLEAN DEFAULT FALSE,
ix_names TEXT[],
has_datacenter BOOLEAN DEFAULT FALSE,
population INTEGER,
UNIQUE(name, country)
);
CREATE INDEX IF NOT EXISTS idx_fiber_routes_cities ON fiber_routes(city_a, city_b);
CREATE INDEX IF NOT EXISTS idx_cities_country ON cities(country);
-- ============================================================
-- GENERATED DATASHEETS (WS2)
-- ============================================================
CREATE TABLE IF NOT EXISTS generated_datasheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL CHECK (entity_type IN ('transceiver', 'switch', 'comparison', 'compatibility_matrix')),
entity_id UUID,
entity_ids UUID[], -- for comparison datasheets
branding TEXT DEFAULT 'flexoptix',
format TEXT DEFAULT 'pdf',
r2_key TEXT,
r2_url TEXT,
file_size_bytes BIGINT,
generated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days'
);
CREATE INDEX IF NOT EXISTS idx_datasheets_entity ON generated_datasheets(entity_type, entity_id);