190 lines
8.2 KiB
TypeScript
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) });
|
|
}
|
|
});
|