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
234 lines
9.5 KiB
TypeScript
234 lines
9.5 KiB
TypeScript
/**
|
|
* WS3: Transport System Planner
|
|
*
|
|
* "Berlin to Darmstadt, 100G" → complete BOM with switches, fiber providers, Flexoptix transceivers
|
|
*/
|
|
import { Router } from "express";
|
|
import { pool } from "../db/client";
|
|
|
|
export const transportRouter = Router();
|
|
|
|
// Haversine distance calculation
|
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
const R = 6371;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
}
|
|
|
|
/**
|
|
* POST /api/transport/plan
|
|
* Body: { from, to, bandwidth_gbps, redundancy?, budget_preference? }
|
|
*/
|
|
transportRouter.post("/plan", async (req, res) => {
|
|
try {
|
|
const { from, to, bandwidth_gbps = 100, redundancy = false, budget_preference = "balanced" } = req.body;
|
|
|
|
if (!from || !to) {
|
|
return res.status(400).json({ error: "Parameters 'from' and 'to' are required" });
|
|
}
|
|
|
|
// 1. Resolve cities
|
|
const cityA = await pool.query(`SELECT * FROM cities WHERE name ILIKE $1 LIMIT 1`, [from]);
|
|
const cityB = await pool.query(`SELECT * FROM cities WHERE name ILIKE $1 LIMIT 1`, [to]);
|
|
|
|
if (!cityA.rows[0] || !cityB.rows[0]) {
|
|
const allCities = await pool.query(`SELECT name, country FROM cities ORDER BY name`);
|
|
return res.status(404).json({
|
|
error: `City not found: ${!cityA.rows[0] ? from : to}`,
|
|
available_cities: allCities.rows.map(c => `${c.name} (${c.country})`),
|
|
});
|
|
}
|
|
|
|
const a = cityA.rows[0];
|
|
const b = cityB.rows[0];
|
|
|
|
// 2. Calculate distance
|
|
const straightKm = haversineKm(parseFloat(a.lat), parseFloat(a.lon), parseFloat(b.lat), parseFloat(b.lon));
|
|
const fiberKm = Math.round(straightKm * 1.4); // fiber route multiplier
|
|
|
|
// 3. Determine transceiver requirements based on distance
|
|
const transceiverOptions = determineTransceiverOptions(fiberKm, bandwidth_gbps);
|
|
|
|
// 4. Find fiber providers for this route
|
|
const providers = await pool.query(
|
|
`SELECT fp.name, fp.website, fp.type, fp.products,
|
|
fr.product_type, fr.monthly_price_eur, fr.setup_fee_eur, fr.min_contract_months
|
|
FROM fiber_routes fr
|
|
JOIN fiber_providers fp ON fr.provider_id = fp.id
|
|
WHERE (fr.city_a ILIKE $1 AND fr.city_b ILIKE $2)
|
|
OR (fr.city_a ILIKE $2 AND fr.city_b ILIKE $1)
|
|
OR (fr.city_a ILIKE $1 AND fr.city_b ILIKE 'Frankfurt%')
|
|
OR (fr.city_a ILIKE 'Frankfurt%' AND fr.city_b ILIKE $2)
|
|
ORDER BY fr.monthly_price_eur ASC NULLS LAST`,
|
|
[from, to]
|
|
);
|
|
|
|
// 5. Find matching switches
|
|
const switchOptions = await pool.query(
|
|
`SELECT sw.id, sw.model, sw.series, sw.max_speed_gbps, sw.switching_capacity_tbps,
|
|
sw.ports_config, sw.msrp_usd, v.name AS vendor
|
|
FROM switches sw JOIN vendors v ON sw.vendor_id = v.id
|
|
WHERE sw.max_speed_gbps >= $1
|
|
AND sw.lifecycle_status NOT IN ('End-of-Life', 'End-of-Sale')
|
|
ORDER BY sw.msrp_usd ASC NULLS LAST, sw.max_speed_gbps DESC
|
|
LIMIT 10`,
|
|
[bandwidth_gbps]
|
|
);
|
|
|
|
// 6. Find Flexoptix transceivers for each option
|
|
const options = [];
|
|
for (const tcvrOpt of transceiverOptions) {
|
|
const flexoptix = await pool.query(
|
|
`SELECT t.id, t.slug, t.form_factor, t.speed_gbps, t.reach_label, t.reach_meters,
|
|
t.fiber_type, t.connector, t.image_url,
|
|
(SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS price,
|
|
(SELECT po.currency FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS currency
|
|
FROM transceivers t
|
|
JOIN vendors v ON t.vendor_id = v.id
|
|
WHERE t.speed_gbps >= $1
|
|
AND t.reach_meters >= $2
|
|
AND t.fiber_type = 'SMF'
|
|
AND v.slug = 'flexoptix'
|
|
ORDER BY t.speed_gbps ASC, t.reach_meters ASC
|
|
LIMIT 5`,
|
|
[tcvrOpt.speed_gbps, tcvrOpt.min_reach_m]
|
|
);
|
|
|
|
// If no Flexoptix match, find any compatible transceiver
|
|
const anyMatch = flexoptix.rows.length > 0 ? flexoptix.rows : (await pool.query(
|
|
`SELECT t.id, t.slug, t.form_factor, t.speed_gbps, t.reach_label, t.reach_meters,
|
|
t.fiber_type, t.connector, t.image_url, v.name AS vendor,
|
|
(SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS price
|
|
FROM transceivers t JOIN vendors v ON t.vendor_id = v.id
|
|
WHERE t.speed_gbps >= $1 AND t.reach_meters >= $2 AND t.fiber_type = 'SMF'
|
|
ORDER BY t.speed_gbps ASC LIMIT 5`,
|
|
[tcvrOpt.speed_gbps, tcvrOpt.min_reach_m]
|
|
)).rows;
|
|
|
|
const spanCount = Math.ceil(fiberKm * 1000 / tcvrOpt.max_span_m);
|
|
const tcvrCount = redundancy ? spanCount * 4 : spanCount * 2; // 2 per span (both ends), x2 for redundancy
|
|
|
|
const tcvrPrice = anyMatch[0]?.price ? parseFloat(anyMatch[0].price) : tcvrOpt.est_price_eur;
|
|
const totalTcvrCost = tcvrCount * tcvrPrice;
|
|
|
|
options.push({
|
|
name: tcvrOpt.name,
|
|
description: tcvrOpt.description,
|
|
transceiver: {
|
|
type: `${tcvrOpt.speed_gbps}G ${tcvrOpt.reach_label}`,
|
|
form_factor: tcvrOpt.form_factor,
|
|
spans_needed: spanCount,
|
|
units_needed: tcvrCount,
|
|
unit_price_est: tcvrPrice,
|
|
total_cost_est: totalTcvrCost,
|
|
flexoptix_products: anyMatch.map(m => ({
|
|
slug: m.slug,
|
|
speed: m.speed_gbps + 'G',
|
|
reach: m.reach_label,
|
|
price: m.price ? parseFloat(m.price) : null,
|
|
buy_url: `https://www.flexoptix.net/en/catalogsearch/result/?q=${encodeURIComponent(m.form_factor + ' ' + m.speed_gbps + 'G ' + m.reach_label)}`,
|
|
})),
|
|
},
|
|
switches: switchOptions.rows.slice(0, 3).map(sw => ({
|
|
model: sw.model,
|
|
vendor: sw.vendor,
|
|
max_speed: sw.max_speed_gbps + 'G',
|
|
price_est: sw.msrp_usd ? parseFloat(sw.msrp_usd) : null,
|
|
})),
|
|
fiber_providers: providers.rows.length > 0 ? providers.rows : [
|
|
{ name: "Contact local fiber providers", note: `No pre-seeded routes for ${from}↔${to}. Check euNetworks, Telia, DTAG.` }
|
|
],
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
route: {
|
|
from: a.name,
|
|
to: b.name,
|
|
straight_line_km: Math.round(straightKm),
|
|
estimated_fiber_km: fiberKm,
|
|
bandwidth_requested: bandwidth_gbps + 'G',
|
|
redundancy,
|
|
},
|
|
options,
|
|
note: "Prices are estimates. Contact Flexoptix sales for volume pricing.",
|
|
});
|
|
} catch (err) {
|
|
console.error("Transport planner error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
function determineTransceiverOptions(fiberKm: number, bandwidthGbps: number) {
|
|
const options = [];
|
|
|
|
if (fiberKm <= 2) {
|
|
options.push({
|
|
name: `${bandwidthGbps}G FR (2km)`,
|
|
description: `Short reach — single span, no amplification needed`,
|
|
speed_gbps: bandwidthGbps, reach_label: 'FR', form_factor: bandwidthGbps >= 400 ? 'QSFP-DD' : 'QSFP28',
|
|
min_reach_m: 2000, max_span_m: 2000, est_price_eur: bandwidthGbps >= 400 ? 200 : 80,
|
|
});
|
|
}
|
|
if (fiberKm <= 10) {
|
|
options.push({
|
|
name: `${bandwidthGbps}G LR4 (10km)`,
|
|
description: `Metro reach — ${Math.ceil(fiberKm / 10)} span(s)`,
|
|
speed_gbps: bandwidthGbps, reach_label: 'LR4', form_factor: bandwidthGbps >= 400 ? 'QSFP-DD' : 'QSFP28',
|
|
min_reach_m: 10000, max_span_m: 10000, est_price_eur: bandwidthGbps >= 400 ? 400 : 120,
|
|
});
|
|
}
|
|
if (fiberKm <= 40) {
|
|
options.push({
|
|
name: `${bandwidthGbps}G ER4 (40km)`,
|
|
description: `Extended reach — ${Math.ceil(fiberKm / 40)} span(s)`,
|
|
speed_gbps: bandwidthGbps, reach_label: 'ER4', form_factor: bandwidthGbps >= 400 ? 'QSFP-DD' : 'QSFP28',
|
|
min_reach_m: 40000, max_span_m: 40000, est_price_eur: bandwidthGbps >= 400 ? 1500 : 400,
|
|
});
|
|
}
|
|
// ZR is always an option for long distances
|
|
if (fiberKm > 10) {
|
|
options.push({
|
|
name: `${Math.min(bandwidthGbps, 400)}G ZR Coherent (80km/span)`,
|
|
description: `Coherent DWDM — ${Math.ceil(fiberKm / 80)} span(s), OIF 400ZR`,
|
|
speed_gbps: Math.min(bandwidthGbps, 400), reach_label: 'ZR', form_factor: 'QSFP-DD',
|
|
min_reach_m: 80000, max_span_m: 80000, est_price_eur: 2500,
|
|
});
|
|
}
|
|
// Carrier wavelength option
|
|
options.push({
|
|
name: `Carrier Wavelength Service (${bandwidthGbps}G)`,
|
|
description: `Managed service — provider handles fiber + amplification. You only need LR4 transceivers at each end.`,
|
|
speed_gbps: bandwidthGbps, reach_label: 'LR4', form_factor: bandwidthGbps >= 400 ? 'QSFP-DD' : 'QSFP28',
|
|
min_reach_m: 10000, max_span_m: 999000, est_price_eur: bandwidthGbps >= 400 ? 400 : 120,
|
|
});
|
|
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* GET /api/transport/cities
|
|
*/
|
|
transportRouter.get("/cities", async (_req, res) => {
|
|
try {
|
|
const result = await pool.query(`SELECT name, country, has_ix, ix_names, has_datacenter FROM cities ORDER BY name`);
|
|
res.json({ cities: result.rows, total: result.rowCount });
|
|
} catch (err) {
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/transport/providers
|
|
*/
|
|
transportRouter.get("/providers", async (_req, res) => {
|
|
try {
|
|
const result = await pool.query(`SELECT * FROM fiber_providers ORDER BY name`);
|
|
res.json({ providers: result.rows, total: result.rowCount });
|
|
} catch (err) {
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|