190 lines
8.2 KiB
TypeScript

/**
* 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<string, any>;
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) });
}
});