/** * Win/Loss Intelligence — /api/win-loss * * Record and analyze deal outcomes: who won, who lost, at what price, in which segment. * * Routes: * POST /api/win-loss — Record a win/loss event * GET /api/win-loss — List events (filterable) * GET /api/win-loss/summary — Aggregate win rate, avg price delta, segments * GET /api/win-loss/competitors — Ranking by competitor vendor (loss analysis) */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; import { sendCSV } from "../utils/csv"; export const winLossRouter = Router(); // ── POST /api/win-loss — Record a deal outcome ────────────────────────────── winLossRouter.post("/", async (req: Request, res: Response) => { const { outcome, transceiver_id, competitor_vendor, our_price, competitor_price, currency = "USD", quantity, customer_segment, deal_source, form_factor, speed_gbps, notes, deal_date, } = req.body as Record; if (!outcome || !["won","lost","unknown"].includes(outcome)) { return res.status(400).json({ success: false, error: "outcome must be: won | lost | unknown" }); } try { const result = await pool.query( `INSERT INTO win_loss_events (outcome, transceiver_id, competitor_vendor, our_price, competitor_price, currency, quantity, customer_segment, deal_source, form_factor, speed_gbps, notes, deal_date) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, COALESCE($13::date, CURRENT_DATE)) RETURNING *`, [ outcome, transceiver_id || null, competitor_vendor || null, our_price ? parseFloat(our_price) : null, competitor_price ? parseFloat(competitor_price) : null, currency, quantity ? parseInt(quantity) : null, customer_segment || null, deal_source || null, form_factor || null, speed_gbps ? parseFloat(speed_gbps) : null, notes || null, deal_date || null, ] ); return res.status(201).json({ success: true, event: result.rows[0] }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── GET /api/win-loss — List events ───────────────────────────────────────── winLossRouter.get("/", async (req: Request, res: Response) => { const outcome = req.query.outcome as string | undefined; const segment = req.query.customer_segment as string | undefined; const days = Math.min(parseInt(req.query.days as string) || 90, 730); const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); const fmt = req.query.format as string | undefined; try { const result = await pool.query(` SELECT wl.*, t.standard_name, t.form_factor AS tx_form_factor, t.speed_gbps AS tx_speed FROM win_loss_events wl LEFT JOIN transceivers t ON t.id = wl.transceiver_id WHERE wl.deal_date >= CURRENT_DATE - INTERVAL '${days} days' ${outcome ? `AND wl.outcome = '${outcome.replace(/'/g,"''")}'` : ""} ${segment ? `AND wl.customer_segment = '${segment.replace(/'/g,"''")}'` : ""} ORDER BY wl.deal_date DESC LIMIT ${limit} `); if (fmt === "csv") { return sendCSV(res, result.rows, `tip-win-loss-${new Date().toISOString().slice(0,10)}.csv`); } return res.json({ success: true, events: result.rows, count: result.rows.length }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── GET /api/win-loss/summary — Aggregate analytics ───────────────────────── winLossRouter.get("/summary", async (req: Request, res: Response) => { const days = Math.min(parseInt(req.query.days as string) || 90, 730); try { const [overall, bySegment, byFormFactor, priceDeltas] = await Promise.all([ pool.query(` SELECT COUNT(*) AS total_events, COUNT(*) FILTER (WHERE outcome = 'won') AS won, COUNT(*) FILTER (WHERE outcome = 'lost') AS lost, ROUND( COUNT(*) FILTER (WHERE outcome = 'won')::numeric / NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')), 0) * 100, 1 ) AS win_rate_pct, ROUND(AVG(our_price) FILTER (WHERE outcome = 'won')::numeric, 2) AS avg_win_price, ROUND(AVG(our_price) FILTER (WHERE outcome = 'lost')::numeric, 2) AS avg_loss_price FROM win_loss_events WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' `), pool.query(` SELECT customer_segment, COUNT(*) AS total, COUNT(*) FILTER (WHERE outcome = 'won') AS won, COUNT(*) FILTER (WHERE outcome = 'lost') AS lost, ROUND(COUNT(*) FILTER (WHERE outcome = 'won')::numeric / NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')),0)*100,1) AS win_rate_pct FROM win_loss_events WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' AND customer_segment IS NOT NULL GROUP BY customer_segment ORDER BY total DESC `), pool.query(` SELECT COALESCE(wl.form_factor, tx.form_factor) AS form_factor, COUNT(*) AS total, COUNT(*) FILTER (WHERE outcome = 'won') AS won, COUNT(*) FILTER (WHERE outcome = 'lost') AS lost FROM win_loss_events wl LEFT JOIN transceivers tx ON tx.id = wl.transceiver_id WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' GROUP BY COALESCE(wl.form_factor, tx.form_factor) HAVING COALESCE(wl.form_factor, tx.form_factor) IS NOT NULL ORDER BY total DESC `), // Price delta analysis: where we lost — how far off were we? pool.query(` SELECT ROUND(AVG(competitor_price - our_price)::numeric, 2) AS avg_price_gap, ROUND(AVG((competitor_price - our_price) / NULLIF(our_price,0) * 100)::numeric, 1) AS avg_gap_pct, COUNT(*) AS events_with_prices FROM win_loss_events WHERE outcome = 'lost' AND our_price IS NOT NULL AND competitor_price IS NOT NULL AND deal_date >= CURRENT_DATE - INTERVAL '${days} days' `), ]); return res.json({ success: true, days, overall: overall.rows[0], by_segment: bySegment.rows, by_form_factor: byFormFactor.rows, price_delta: priceDeltas.rows[0], }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── GET /api/win-loss/competitors — Competitor ranking ─────────────────────── winLossRouter.get("/competitors", async (req: Request, res: Response) => { const days = Math.min(parseInt(req.query.days as string) || 90, 730); try { const result = await pool.query(` SELECT competitor_vendor, COUNT(*) AS encounters, COUNT(*) FILTER (WHERE outcome = 'lost') AS losses_to, COUNT(*) FILTER (WHERE outcome = 'won') AS wins_against, ROUND(AVG(competitor_price - our_price) FILTER (WHERE outcome = 'lost' AND our_price IS NOT NULL AND competitor_price IS NOT NULL) ::numeric, 2) AS avg_price_advantage, -- negative = they beat us on price ROUND(AVG(competitor_price)::numeric, 2) AS avg_competitor_price FROM win_loss_events WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days' AND competitor_vendor IS NOT NULL GROUP BY competitor_vendor ORDER BY losses_to DESC, encounters DESC LIMIT 30 `); return res.json({ success: true, competitors: result.rows }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } });