const express = require('express'); const router = express.Router(); // Stale PO statuses to exclude from our counting — these are the "still active" // statuses as defined in scripts/metrics-new/update_product_metrics.sql. POs that // are canceled or done are excluded separately. const OPEN_PO_STATUSES = [ 'created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started' ]; // GET /api/repeat-orders // Finds products that are repeatedly appearing on small POs to a given supplier, // indicating the auto-PO pattern where we keep buying MOQ instead of batching. // // Design notes on dedup between POs and receivings: // - We count PO line appearances (not unit totals) as the frequency signal, since // that is exactly the user action we are trying to detect (repeated small orders). // - Canceled POs are filtered out so unreceived garbage cannot inflate the count. // - Receivings are joined for read-only context (units actually arrived) and are // NOT added to the frequency count, so there is no double-counting between POs // and their matching receivings. // - Stock / on-order / replenishment are pulled from product_metrics, which uses // the canonical FIFO logic in update_product_metrics.sql to avoid double-counting // units between POs and their receivings. // // Query params: // supplierId (int, default 92 = Notions) // windowDays (int, default 30, range 1..180) // minPoCount (int, default 3) // maxAvgQty (number, default 10 — filters out legitimate large batch orders) router.get('/', async (req, res) => { const supplierId = parseInt(req.query.supplierId, 10); const windowDays = Math.min(180, Math.max(1, parseInt(req.query.windowDays, 10) || 30)); const minPoCount = Math.max(2, parseInt(req.query.minPoCount, 10) || 3); const maxAvgQtyRaw = parseFloat(req.query.maxAvgQty); const maxAvgQty = Number.isFinite(maxAvgQtyRaw) && maxAvgQtyRaw > 0 ? maxAvgQtyRaw : 10; if (!Number.isFinite(supplierId)) { return res.status(400).json({ error: 'supplierId query param is required' }); } try { const pool = req.app.locals.pool; const { rows } = await pool.query( ` WITH po_window AS ( SELECT po.pid, po.po_id, po.date, po.ordered, po.po_cost_price, po.status FROM purchase_orders po WHERE po.supplier_id = $1 AND po.date >= NOW() - ($2::int * INTERVAL '1 day') AND po.status <> 'canceled' ), po_agg AS ( SELECT pid, COUNT(*)::int AS po_line_count, COUNT(DISTINCT date::date)::int AS po_days_active, SUM(ordered)::int AS po_total_units, AVG(ordered::numeric)::numeric(10,2) AS po_avg_qty, MIN(ordered)::int AS po_min_qty, MAX(ordered)::int AS po_max_qty, MIN(date)::date AS first_po_date, MAX(date)::date AS last_po_date, SUM(ordered * po_cost_price)::numeric(14,2) AS po_total_cost, -- Count of PO lines still in an open status (not yet received / closed) COUNT(*) FILTER (WHERE status = ANY($5::text[]))::int AS po_open_line_count FROM po_window GROUP BY pid HAVING COUNT(*) >= $3 AND AVG(ordered::numeric) <= $4 ), recv_window AS ( SELECT pid, COUNT(*)::int AS recv_line_count, COUNT(DISTINCT received_date::date)::int AS recv_days_active, SUM(qty_each)::int AS recv_total_units, MAX(received_date)::date AS last_recv_date FROM receivings WHERE supplier_id = $1 AND received_date >= NOW() - ($2::int * INTERVAL '1 day') AND status IN ('partial_received', 'full_received', 'paid') GROUP BY pid ), forecast_agg AS ( SELECT pid, SUM(forecast_units) FILTER ( WHERE forecast_date >= CURRENT_DATE AND forecast_date < CURRENT_DATE + INTERVAL '30 days' )::numeric(10,2) AS forecast_30d, SUM(forecast_units) FILTER ( WHERE forecast_date >= CURRENT_DATE AND forecast_date < CURRENT_DATE + INTERVAL '60 days' )::numeric(10,2) AS forecast_60d, MAX(lifecycle_phase) AS lifecycle_phase FROM product_forecasts WHERE pid IN (SELECT pid FROM po_agg) GROUP BY pid ) SELECT pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.image_url, pm.is_visible, pm.is_replenishable, pm.moq, pm.current_stock, pm.notions_inv_count, pm.on_order_qty, pm.sales_30d, pm.sales_velocity_daily::numeric(10,3) AS velocity, pm.stock_cover_in_days::numeric(10,1) AS cover_days, pm.replenishment_units, pm.to_order_units, pm.avg_lead_time_days, pm.lifecycle_phase, pm.earliest_expected_date, po.po_line_count, po.po_days_active, po.po_total_units, po.po_avg_qty, po.po_min_qty, po.po_max_qty, po.first_po_date, po.last_po_date, po.po_total_cost, po.po_open_line_count, COALESCE(r.recv_line_count, 0) AS recv_line_count, COALESCE(r.recv_days_active, 0) AS recv_days_active, COALESCE(r.recv_total_units, 0) AS recv_total_units, r.last_recv_date, f.forecast_30d, f.forecast_60d FROM po_agg po JOIN product_metrics pm ON pm.pid = po.pid LEFT JOIN recv_window r ON r.pid = po.pid LEFT JOIN forecast_agg f ON f.pid = po.pid ORDER BY po.po_line_count DESC, pm.sales_30d DESC NULLS LAST LIMIT 500 `, [supplierId, windowDays, minPoCount, maxAvgQty, OPEN_PO_STATUSES] ); // Supplier-level summary for the filters in effect — independent of the // minPoCount / maxAvgQty filters so the user can see the full baseline. const { rows: summaryRows } = await pool.query( ` SELECT COUNT(*)::int AS total_po_lines, COUNT(DISTINCT po_id)::int AS distinct_po_ids, COUNT(DISTINCT pid)::int AS distinct_products, COUNT(DISTINCT date::date)::int AS distinct_days, SUM(ordered)::int AS total_units, AVG(ordered::numeric)::numeric(10,2) AS avg_qty_per_line FROM purchase_orders WHERE supplier_id = $1 AND date >= NOW() - ($2::int * INTERVAL '1 day') AND status <> 'canceled' `, [supplierId, windowDays] ); const results = rows.map((row) => { const poAvg = parseFloat(row.po_avg_qty) || 0; const velocity = parseFloat(row.velocity) || 0; const forecast30 = parseFloat(row.forecast_30d) || 0; const poCount = parseInt(row.po_line_count, 10) || 0; const poDays = parseInt(row.po_days_active, 10) || 0; // Repeat score: higher = more consolidation opportunity. // - poCount boosts the score // - inversely weighted by avg qty (small buys = bigger problem) // - boosted by velocity (real demand, not noise) const repeatScore = poAvg > 0 ? Number(((poCount * Math.max(velocity, 0.1)) / poAvg).toFixed(2)) : 0; return { pid: row.pid, sku: row.sku, title: row.title, brand: row.brand, vendor: row.vendor, imageUrl: row.image_url, isVisible: row.is_visible, isReplenishable: row.is_replenishable, moq: row.moq, currentStock: row.current_stock, notionsInvCount: row.notions_inv_count, onOrderQty: row.on_order_qty, sales30d: row.sales_30d, velocity, coverDays: row.cover_days !== null ? parseFloat(row.cover_days) : null, replenishmentUnits: row.replenishment_units, toOrderUnits: row.to_order_units, avgLeadTimeDays: row.avg_lead_time_days, lifecyclePhase: row.lifecycle_phase, earliestExpectedDate: row.earliest_expected_date, poLineCount: poCount, poDaysActive: poDays, poTotalUnits: row.po_total_units, poAvgQty: poAvg, poMinQty: row.po_min_qty, poMaxQty: row.po_max_qty, firstPoDate: row.first_po_date, lastPoDate: row.last_po_date, poTotalCost: parseFloat(row.po_total_cost) || 0, poOpenLineCount: row.po_open_line_count, recvLineCount: row.recv_line_count, recvDaysActive: row.recv_days_active, recvTotalUnits: row.recv_total_units, lastRecvDate: row.last_recv_date, forecast30d: forecast30, forecast60d: parseFloat(row.forecast_60d) || 0, repeatScore, }; }); const summary = summaryRows[0] || {}; res.json({ params: { supplierId, windowDays, minPoCount, maxAvgQty, }, supplierSummary: { totalPoLines: summary.total_po_lines || 0, distinctPoIds: summary.distinct_po_ids || 0, distinctProducts: summary.distinct_products || 0, distinctDays: summary.distinct_days || 0, totalUnits: summary.total_units || 0, avgQtyPerLine: parseFloat(summary.avg_qty_per_line) || 0, }, matchSummary: { matchCount: results.length, totalSmallPoLines: results.reduce((sum, r) => sum + r.poLineCount, 0), totalSmallPoUnits: results.reduce((sum, r) => sum + (r.poTotalUnits || 0), 0), totalSales30d: results.reduce((sum, r) => sum + (r.sales30d || 0), 0), totalSuggestedUnits: results.reduce((sum, r) => sum + (r.replenishmentUnits || 0), 0), }, results, }); } catch (error) { console.error('Error fetching repeat orders:', error); res.status(500).json({ error: 'Failed to fetch repeat orders' }); } }); // GET /api/repeat-orders/suppliers // Returns the list of suppliers that have had non-canceled PO activity recently, // so the UI can offer a supplier dropdown without an expensive scan. router.get('/suppliers', async (req, res) => { const windowDays = Math.min(365, Math.max(7, parseInt(req.query.windowDays, 10) || 90)); try { const pool = req.app.locals.pool; const { rows } = await pool.query( ` SELECT supplier_id, MAX(vendor) AS vendor_name, COUNT(*)::int AS line_count, COUNT(DISTINCT po_id)::int AS po_count, MAX(date)::date AS last_po_date FROM purchase_orders WHERE supplier_id IS NOT NULL AND date >= NOW() - ($1::int * INTERVAL '1 day') AND status <> 'canceled' GROUP BY supplier_id HAVING COUNT(*) >= 5 ORDER BY line_count DESC `, [windowDays] ); res.json({ suppliers: rows.map((r) => ({ supplierId: r.supplier_id, vendorName: r.vendor_name, lineCount: r.line_count, poCount: r.po_count, lastPoDate: r.last_po_date, })), }); } catch (error) { console.error('Error fetching repeat-order suppliers:', error); res.status(500).json({ error: 'Failed to fetch suppliers' }); } }); // GET /api/repeat-orders/:pid/history // Returns the full PO + receiving history within the window for a single product, // for drill-down detail when the user clicks a row. router.get('/:pid/history', async (req, res) => { const pid = parseInt(req.params.pid, 10); const supplierId = parseInt(req.query.supplierId, 10); const windowDays = Math.min(365, Math.max(1, parseInt(req.query.windowDays, 10) || 30)); if (!Number.isFinite(pid) || !Number.isFinite(supplierId)) { return res.status(400).json({ error: 'pid and supplierId are required' }); } try { const pool = req.app.locals.pool; const { rows: poRows } = await pool.query( ` SELECT po_id, date, expected_date, ordered, po_cost_price, status, notes FROM purchase_orders WHERE pid = $1 AND supplier_id = $2 AND date >= NOW() - ($3::int * INTERVAL '1 day') ORDER BY date DESC `, [pid, supplierId, windowDays] ); const { rows: recvRows } = await pool.query( ` SELECT receiving_id, received_date, qty_each, cost_each, status FROM receivings WHERE pid = $1 AND supplier_id = $2 AND received_date >= NOW() - ($3::int * INTERVAL '1 day') ORDER BY received_date DESC `, [pid, supplierId, windowDays] ); res.json({ pid, supplierId, purchaseOrders: poRows.map((r) => ({ poId: r.po_id, date: r.date, expectedDate: r.expected_date, ordered: r.ordered, costPrice: parseFloat(r.po_cost_price) || 0, status: r.status, notes: r.notes, })), receivings: recvRows.map((r) => ({ receivingId: r.receiving_id, receivedDate: r.received_date, qtyEach: r.qty_each, costEach: parseFloat(r.cost_each) || 0, status: r.status, })), }); } catch (error) { console.error('Error fetching repeat-order history:', error); res.status(500).json({ error: 'Failed to fetch product history' }); } }); module.exports = router;