const express = require('express'); const router = express.Router(); // Status code constants // Frontend uses these numeric codes but database uses strings const STATUS = { CANCELED: 0, CREATED: 1, ELECTRONICALLY_READY_SEND: 10, ORDERED: 11, PREORDERED: 12, ELECTRONICALLY_SENT: 13, RECEIVING_STARTED: 15, DONE: 50, // Receiving status codes RECEIVING_CREATED: 1, RECEIVING_PARTIAL: 30, RECEIVING_FULL: 40, RECEIVING_PAID: 50 }; // Status mapping from database string values to frontend numeric codes const STATUS_MAPPING = { 'canceled': STATUS.CANCELED, 'created': STATUS.CREATED, 'electronically_ready_send': STATUS.ELECTRONICALLY_READY_SEND, 'ordered': STATUS.ORDERED, 'preordered': STATUS.PREORDERED, 'electronically_sent': STATUS.ELECTRONICALLY_SENT, 'receiving_started': STATUS.RECEIVING_STARTED, 'done': STATUS.DONE, // Receiving status mappings 'partial_received': STATUS.RECEIVING_PARTIAL, 'full_received': STATUS.RECEIVING_FULL, 'paid': STATUS.RECEIVING_PAID }; // Helper for SQL status value comparison with string values in DB function getStatusWhereClause(statusNum) { const dbStatuses = Object.keys(STATUS_MAPPING).filter(key => STATUS_MAPPING[key] === parseInt(statusNum)); if (dbStatuses.length > 0) { return `po.status = '${dbStatuses[0]}'`; } return `1=0`; // No match found, return false condition } // Get all purchase orders with summary metrics router.get('/', async (req, res) => { try { const pool = req.app.locals.pool; // Parse query parameters with defaults const { search = '', status = 'all', vendor = 'all', recordType = 'all', startDate = null, endDate = null, page = 1, limit = 100, sortColumn = 'id', sortDirection = 'desc' } = req.query; console.log("Received query parameters:", { search, status, vendor, recordType, page, limit, sortColumn, sortDirection }); // Base where clause for purchase orders let poWhereClause = '1=1'; // Base where clause for receivings (used in the receiving_data CTE) let receivingWhereClause = '1=1'; const params = []; let paramCounter = 1; if (search && search.trim() !== '') { // Simplified search for purchase orders - improved performance const searchTerm = `%${search.trim()}%`; poWhereClause += ` AND ( po.po_id::text ILIKE $${paramCounter} OR po.vendor ILIKE $${paramCounter} OR po.notes ILIKE $${paramCounter} )`; params.push(searchTerm); paramCounter++; // Add search for receivings receivingWhereClause += ` AND ( r.receiving_id::text ILIKE $${paramCounter} OR r.vendor ILIKE $${paramCounter} )`; params.push(searchTerm); paramCounter++; } if (status && status !== 'all') { poWhereClause += ` AND ${getStatusWhereClause(status)}`; // Handle status for receivings const dbStatuses = Object.keys(STATUS_MAPPING).filter(key => STATUS_MAPPING[key] === parseInt(status)); if (dbStatuses.length > 0) { receivingWhereClause += ` AND r.status = '${dbStatuses[0]}'`; } } if (vendor && vendor !== 'all') { poWhereClause += ` AND po.vendor = $${paramCounter}`; params.push(vendor); paramCounter++; // Add vendor filter for receivings receivingWhereClause += ` AND r.vendor = $${paramCounter}`; params.push(vendor); paramCounter++; } if (startDate) { poWhereClause += ` AND po.date >= $${paramCounter}::date`; params.push(startDate); paramCounter++; // Add date filter for receivings receivingWhereClause += ` AND r.received_date >= $${paramCounter}::date`; params.push(startDate); paramCounter++; } if (endDate) { poWhereClause += ` AND po.date <= $${paramCounter}::date`; params.push(endDate); paramCounter++; // Add date filter for receivings receivingWhereClause += ` AND r.received_date <= $${paramCounter}::date`; params.push(endDate); paramCounter++; } // Get filtered summary metrics const summaryQuery = ` WITH po_totals AS ( SELECT po_id, SUM(ordered) as total_ordered, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id ), receiving_totals AS ( SELECT r.receiving_id as po_id, SUM(r.qty_each) as total_received FROM receivings r WHERE (${receivingWhereClause}) AND r.receiving_id IN (SELECT po_id FROM po_totals) GROUP BY r.receiving_id ) SELECT COUNT(DISTINCT po.po_id) as order_count, SUM(po.total_ordered) as total_ordered, COALESCE(SUM(r.total_received), 0) as total_received, CASE WHEN SUM(po.total_ordered) > 0 THEN ROUND((COALESCE(SUM(r.total_received), 0)::numeric / SUM(po.total_ordered)), 3) ELSE 0 END as fulfillment_rate, ROUND(SUM(po.total_cost)::numeric, 3) as total_value, CASE WHEN COUNT(DISTINCT po.po_id) > 0 THEN ROUND(AVG(po.total_cost)::numeric, 3) ELSE 0 END as avg_cost FROM po_totals po LEFT JOIN receiving_totals r ON po.po_id = r.po_id `; const { rows: [summary] } = await pool.query(summaryQuery, params); // Prepare query based on record type filter to get correct counts let countQuery = ''; if (recordType === 'po_only') { countQuery = ` WITH po_data AS ( SELECT po_id FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id ), receiving_data AS ( SELECT receiving_id FROM receivings r WHERE ${receivingWhereClause} GROUP BY receiving_id ), filtered_data AS ( SELECT DISTINCT po_id as id FROM po_data WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) ) SELECT COUNT(*) as total FROM filtered_data `; } else if (recordType === 'po_with_receiving') { countQuery = ` WITH po_data AS ( SELECT po_id FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id ), receiving_data AS ( SELECT receiving_id FROM receivings r WHERE ${receivingWhereClause} GROUP BY receiving_id ), filtered_data AS ( SELECT DISTINCT po_id as id FROM po_data WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) ) SELECT COUNT(*) as total FROM filtered_data `; } else if (recordType === 'receiving_only') { countQuery = ` WITH po_data AS ( SELECT po_id FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id ), receiving_data AS ( SELECT receiving_id FROM receivings r WHERE ${receivingWhereClause} GROUP BY receiving_id ), filtered_data AS ( SELECT DISTINCT receiving_id as id FROM receiving_data WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) ) SELECT COUNT(*) as total FROM filtered_data `; } else { // 'all' - count both purchase orders and receiving-only records countQuery = ` WITH po_data AS ( SELECT po_id FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id ), receiving_data AS ( SELECT receiving_id FROM receivings r WHERE ${receivingWhereClause} GROUP BY receiving_id ), filtered_data AS ( SELECT DISTINCT po_id as id FROM po_data UNION SELECT DISTINCT receiving_id as id FROM receiving_data WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) ) SELECT COUNT(*) as total FROM filtered_data `; } const { rows: [countResult] } = await pool.query(countQuery, params); // Parse parameters safely const parsedPage = parseInt(page) || 1; const parsedLimit = parseInt(limit) || 100; const total = parseInt(countResult?.total) || 0; const offset = (parsedPage - 1) * parsedLimit; const pages = Math.ceil(total / parsedLimit); // Validated sort parameters const validSortColumns = ['id', 'vendor_name', 'order_date', 'receiving_date', 'status', 'total_cost', 'total_items', 'total_quantity', 'total_received', 'fulfillment_rate']; const finalSortColumn = validSortColumns.includes(sortColumn) ? sortColumn : 'id'; const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; // Build the order by clause with improved null handling let orderByClause = ''; // Special sorting that ensures receiving_only records are included with any date sorting if (finalSortColumn === 'order_date' || finalSortColumn === 'date') { orderByClause = ` CASE WHEN order_date IS NULL THEN CASE WHEN receiving_date IS NOT NULL THEN to_date(receiving_date, 'YYYY-MM-DD') ELSE '1900-01-01'::date END ELSE to_date(order_date, 'YYYY-MM-DD') END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} `; } else if (finalSortColumn === 'receiving_date') { orderByClause = ` CASE WHEN receiving_date IS NULL THEN '1900-01-01'::date ELSE to_date(receiving_date, 'YYYY-MM-DD') END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} `; } else if (finalSortColumn === 'vendor_name') { orderByClause = `vendor_name ${finalSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; } else if (finalSortColumn === 'total_cost' || finalSortColumn === 'total_received' || finalSortColumn === 'total_items' || finalSortColumn === 'total_quantity' || finalSortColumn === 'fulfillment_rate') { orderByClause = `COALESCE(${finalSortColumn}, 0) ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`; } else if (finalSortColumn === 'status') { // For status sorting, first convert to numeric values for consistent sorting orderByClause = ` CASE WHEN status = 'canceled' THEN 0 WHEN status = 'created' THEN 1 WHEN status = 'electronically_ready_send' THEN 10 WHEN status = 'ordered' THEN 11 WHEN status = 'receiving_started' THEN 15 WHEN status = 'done' THEN 50 WHEN status = 'partial_received' THEN 30 WHEN status = 'full_received' THEN 40 WHEN status = 'paid' THEN 50 ELSE 999 END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'} `; } else { // Default to ID sorting orderByClause = `id::text::bigint ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`; } // Main query to get purchase orders and receivings let orderQuery = ` WITH po_data AS ( SELECT po_id, vendor, date, status, COUNT(DISTINCT pid) as total_items, SUM(ordered) as total_quantity, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, MAX(notes) as short_note FROM purchase_orders po WHERE ${poWhereClause} GROUP BY po_id, vendor, date, status ), receiving_data AS ( SELECT r.receiving_id, MAX(r.received_date) as receiving_date, r.vendor as receiving_vendor, COUNT(DISTINCT r.pid) as total_items, SUM(r.qty_each) as total_received, ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost, MAX(r.status) as receiving_status FROM receivings r WHERE ${receivingWhereClause} GROUP BY r.receiving_id, r.vendor )`; // Add appropriate record type filtering based on the filter value if (recordType === 'po_only') { orderQuery += `, all_data AS ( SELECT DISTINCT po_id as id FROM po_data WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) )`; } else if (recordType === 'po_with_receiving') { orderQuery += `, all_data AS ( SELECT DISTINCT po_id as id FROM po_data WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL) )`; } else if (recordType === 'receiving_only') { orderQuery += `, all_data AS ( SELECT DISTINCT receiving_id as id FROM receiving_data WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) )`; } else { // 'all' - include all records orderQuery += `, all_data AS ( SELECT DISTINCT po_id as id FROM po_data UNION SELECT DISTINCT receiving_id as id FROM receiving_data WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL) )`; } // Complete the query with combined data and ordering orderQuery += ` ,combined_data AS ( SELECT a.id, COALESCE(po.vendor, r.receiving_vendor) as vendor_name, to_char(po.date, 'YYYY-MM-DD') as order_date, to_char(r.receiving_date, 'YYYY-MM-DD') as receiving_date, CASE WHEN po.po_id IS NULL THEN r.receiving_status ELSE po.status END as status, COALESCE(po.total_items, r.total_items, 0) as total_items, COALESCE(po.total_quantity, 0) as total_quantity, COALESCE(po.total_cost, r.total_cost, 0) as total_cost, COALESCE(r.total_received, 0) as total_received, CASE WHEN po.po_id IS NULL THEN 1 WHEN r.receiving_id IS NULL THEN 0 WHEN po.total_quantity = 0 THEN 0 ELSE ROUND((r.total_received::numeric / po.total_quantity), 3) END as fulfillment_rate, po.short_note, CASE WHEN po.po_id IS NULL THEN 'receiving_only' WHEN r.receiving_id IS NULL THEN 'po_only' ELSE 'po_with_receiving' END as record_type FROM all_data a LEFT JOIN po_data po ON a.id = po.po_id LEFT JOIN receiving_data r ON a.id = r.receiving_id ) SELECT * FROM combined_data ORDER BY ${orderByClause}, id::text::bigint DESC LIMIT $${paramCounter} OFFSET $${paramCounter + 1} `; const { rows: orders } = await pool.query(orderQuery, [...params, parsedLimit, offset]); // Get unique vendors for filter options const { rows: vendors } = await pool.query(` SELECT DISTINCT vendor FROM purchase_orders WHERE vendor IS NOT NULL AND vendor != '' UNION SELECT DISTINCT vendor FROM receivings WHERE vendor IS NOT NULL AND vendor != '' ORDER BY vendor `); // Get unique statuses for filter options const { rows: statuses } = await pool.query(` SELECT DISTINCT status FROM purchase_orders WHERE status IS NOT NULL UNION SELECT DISTINCT status FROM receivings WHERE status IS NOT NULL ORDER BY status `); // Get product vendors for orders with Unknown Vendor const poIds = orders.filter(o => o.vendor_name === 'Unknown Vendor').map(o => o.id); let vendorMappings = {}; if (poIds.length > 0) { const { rows: productVendors } = await pool.query(` SELECT DISTINCT po.po_id, p.vendor FROM purchase_orders po JOIN products p ON po.pid = p.pid WHERE po.po_id = ANY($1) AND p.vendor IS NOT NULL AND p.vendor != '' GROUP BY po.po_id, p.vendor `, [poIds]); // Create mapping of PO ID to actual vendor from products table vendorMappings = productVendors.reduce((acc, pv) => { if (!acc[pv.po_id]) { acc[pv.po_id] = pv.vendor; } return acc; }, {}); } // Parse numeric values and map status strings to numeric codes const parsedOrders = orders.map(order => { // Special handling for status mapping let statusCode; if (order.record_type === 'receiving_only') { // For receiving-only records, use receiving status codes statusCode = STATUS_MAPPING[order.status] || 0; } else { // For PO records, use PO status codes statusCode = STATUS_MAPPING[order.status] || 0; } return { id: order.id, vendor_name: vendorMappings[order.id] || order.vendor_name, order_date: order.order_date, receiving_date: order.receiving_date, status: statusCode, total_items: Number(order.total_items) || 0, total_quantity: Number(order.total_quantity) || 0, total_cost: Number(order.total_cost) || 0, total_received: Number(order.total_received) || 0, fulfillment_rate: Number(order.fulfillment_rate) || 0, short_note: order.short_note, record_type: order.record_type }; }); // Parse summary metrics with fallbacks const parsedSummary = { order_count: Number(summary?.order_count) || 0, total_ordered: Number(summary?.total_ordered) || 0, total_received: Number(summary?.total_received) || 0, fulfillment_rate: Number(summary?.fulfillment_rate) || 0, total_value: Number(summary?.total_value) || 0, avg_cost: Number(summary?.avg_cost) || 0 }; console.log(`Returning ${parsedOrders.length} orders, total=${total}, pages=${pages}, page=${parsedPage}`); res.json({ orders: parsedOrders, summary: parsedSummary, pagination: { total, pages, page: parsedPage, limit: parsedLimit }, filters: { vendors: vendors.map(v => v.vendor), statuses: statuses.map(s => STATUS_MAPPING[s.status] || 0) // Map string statuses to numeric codes for the frontend } }); } catch (error) { console.error('Error fetching purchase orders:', error); res.status(500).json({ error: 'Failed to fetch purchase orders', details: error.message }); } }); // Get vendor performance metrics router.get('/vendor-metrics', async (req, res) => { try { const pool = req.app.locals.pool; const { rows: metrics } = await pool.query(` WITH po_data AS ( SELECT vendor, po_id, SUM(ordered) as total_ordered, AVG(po_cost_price) as avg_cost_price, MAX(date) as po_date FROM purchase_orders WHERE vendor IS NOT NULL AND vendor != '' AND status != 'canceled' -- Exclude canceled orders GROUP BY vendor, po_id ), receiving_data AS ( SELECT r.receiving_id as po_id, SUM(r.qty_each) as total_received, MIN(r.received_date) as first_received_date FROM receivings r JOIN purchase_orders po ON r.receiving_id = po.po_id WHERE po.vendor IS NOT NULL AND po.vendor != '' AND po.status != 'canceled' GROUP BY r.receiving_id ), delivery_metrics AS ( SELECT po.vendor, po.po_id, po.total_ordered as ordered, COALESCE(r.total_received, 0) as received, po.avg_cost_price as po_cost_price, CASE WHEN r.first_received_date IS NOT NULL AND po.po_date IS NOT NULL THEN EXTRACT(DAY FROM (r.first_received_date - po.po_date)) ELSE NULL END as delivery_days FROM po_data po LEFT JOIN receiving_data r ON po.po_id = r.po_id ) SELECT vendor as vendor_name, COUNT(DISTINCT po_id) as total_orders, SUM(ordered) as total_ordered, SUM(received) as total_received, ROUND( (SUM(received)::numeric / NULLIF(SUM(ordered), 0)), 3 ) as fulfillment_rate, ROUND( (SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2 ) as avg_unit_cost, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend, ROUND( AVG(NULLIF(delivery_days, 0))::numeric, 1 ) as avg_delivery_days FROM delivery_metrics GROUP BY vendor HAVING COUNT(DISTINCT po_id) > 0 ORDER BY total_spend DESC `); // Parse numeric values const parsedMetrics = metrics.map(vendor => ({ id: vendor.vendor_name, vendor_name: vendor.vendor_name, total_orders: Number(vendor.total_orders) || 0, total_ordered: Number(vendor.total_ordered) || 0, total_received: Number(vendor.total_received) || 0, fulfillment_rate: Number(vendor.fulfillment_rate) || 0, avg_unit_cost: Number(vendor.avg_unit_cost) || 0, total_spend: Number(vendor.total_spend) || 0, avg_delivery_days: Number(vendor.avg_delivery_days) || 0 })); res.json(parsedMetrics); } catch (error) { console.error('Error fetching vendor metrics:', error); res.status(500).json({ error: 'Failed to fetch vendor metrics' }); } }); // Get cost analysis router.get('/cost-analysis', async (req, res) => { try { const pool = req.app.locals.pool; const { rows: analysis } = await pool.query(` WITH category_costs AS ( SELECT c.name as category, po.pid, po.po_cost_price as cost_price, po.ordered, po.status FROM purchase_orders po JOIN product_categories pc ON po.pid = pc.pid JOIN categories c ON pc.cat_id = c.cat_id WHERE po.status != 'canceled' -- Exclude canceled orders ) SELECT category, COUNT(DISTINCT pid) as unique_products, ROUND(AVG(cost_price)::numeric, 3) as avg_cost, ROUND(MIN(cost_price)::numeric, 3) as min_cost, ROUND(MAX(cost_price)::numeric, 3) as max_cost, ROUND(STDDEV(cost_price)::numeric, 3) as cost_variance, ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend FROM category_costs GROUP BY category ORDER BY total_spend DESC `); // Parse numeric values and include ALL data for each category const parsedAnalysis = { unique_products: 0, avg_cost: 0, min_cost: 0, max_cost: 0, cost_variance: 0, total_spend_by_category: analysis.map(cat => ({ category: cat.category, unique_products: Number(cat.unique_products) || 0, avg_cost: Number(cat.avg_cost) || 0, min_cost: Number(cat.min_cost) || 0, max_cost: Number(cat.max_cost) || 0, cost_variance: Number(cat.cost_variance) || 0, total_spend: Number(cat.total_spend) || 0 })) }; // Calculate aggregated stats if data exists if (analysis.length > 0) { parsedAnalysis.unique_products = analysis.reduce((sum, cat) => sum + Number(cat.unique_products || 0), 0); // Calculate weighted average cost const totalProducts = parsedAnalysis.unique_products; if (totalProducts > 0) { parsedAnalysis.avg_cost = analysis.reduce((sum, cat) => sum + (Number(cat.avg_cost || 0) * Number(cat.unique_products || 0)), 0) / totalProducts; } // Find min and max across all categories parsedAnalysis.min_cost = Math.min(...analysis.map(cat => Number(cat.min_cost || 0))); parsedAnalysis.max_cost = Math.max(...analysis.map(cat => Number(cat.max_cost || 0))); // Average variance parsedAnalysis.cost_variance = analysis.reduce((sum, cat) => sum + Number(cat.cost_variance || 0), 0) / analysis.length; } res.json(parsedAnalysis); } catch (error) { console.error('Error fetching cost analysis:', error); res.status(500).json({ error: 'Failed to fetch cost analysis' }); } }); // New endpoint for yearly category spending analysis based on receivings router.get('/category-analysis', async (req, res) => { try { const pool = req.app.locals.pool; // Allow an optional "since" parameter or default to 1 year ago const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const { rows: analysis } = await pool.query(` WITH receiving_costs AS ( SELECT c.name as category, r.pid, r.cost_each as received_cost, r.qty_each as received_qty, r.received_date FROM receivings r JOIN product_categories pc ON r.pid = pc.pid JOIN categories c ON pc.cat_id = c.cat_id WHERE r.received_date >= $1::date AND r.qty_each > 0 -- Only consider actual received quantities ) SELECT category, COUNT(DISTINCT pid) as unique_products, ROUND(AVG(received_cost)::numeric, 3) as avg_cost, ROUND(MIN(received_cost)::numeric, 3) as min_cost, ROUND(MAX(received_cost)::numeric, 3) as max_cost, ROUND(STDDEV(received_cost)::numeric, 3) as cost_variance, ROUND(SUM(received_qty * received_cost)::numeric, 3) as total_spend FROM receiving_costs GROUP BY category ORDER BY total_spend DESC `, [since]); // Parse numeric values const parsedAnalysis = analysis.map(cat => ({ category: cat.category, unique_products: Number(cat.unique_products) || 0, avg_cost: Number(cat.avg_cost) || 0, min_cost: Number(cat.min_cost) || 0, max_cost: Number(cat.max_cost) || 0, cost_variance: Number(cat.cost_variance) || 0, total_spend: Number(cat.total_spend) || 0 })); res.json(parsedAnalysis); } catch (error) { console.error('Error fetching category analysis:', error); res.status(500).json({ error: 'Failed to fetch category analysis' }); } }); // New endpoint for yearly vendor spending analysis based on receivings router.get('/vendor-analysis', async (req, res) => { try { const pool = req.app.locals.pool; // Allow an optional "since" parameter or default to 1 year ago const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const { rows: metrics } = await pool.query(` WITH receiving_data AS ( SELECT r.vendor, r.receiving_id, r.pid, r.qty_each, r.cost_each, r.received_date FROM receivings r WHERE r.received_date >= $1::date AND r.qty_each > 0 -- Only consider actual received quantities ), receiving_totals AS ( SELECT vendor, receiving_id, COUNT(DISTINCT pid) as unique_products, SUM(qty_each) as total_received, SUM(qty_each * cost_each) as total_spend FROM receiving_data GROUP BY vendor, receiving_id ) SELECT vendor, COUNT(DISTINCT receiving_id) as orders, ROUND(SUM(total_spend)::numeric, 3) as total_spend, SUM(total_received) as total_received, SUM(unique_products) as total_items FROM receiving_totals WHERE vendor IS NOT NULL AND vendor != '' GROUP BY vendor HAVING COUNT(DISTINCT receiving_id) > 0 ORDER BY total_spend DESC `, [since]); // Parse numeric values const parsedMetrics = metrics.map(vendor => ({ vendor: vendor.vendor, orders: Number(vendor.orders) || 0, total_spend: Number(vendor.total_spend) || 0, total_received: Number(vendor.total_received) || 0, total_items: Number(vendor.total_items) || 0 })); res.json(parsedMetrics); } catch (error) { console.error('Error fetching vendor analysis:', error); res.status(500).json({ error: 'Failed to fetch vendor analysis' }); } }); // Get order status metrics router.get('/receiving-status', async (req, res) => { try { const pool = req.app.locals.pool; const { rows: status } = await pool.query(` WITH po_totals AS ( SELECT po_id, status, SUM(ordered) as total_ordered, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost FROM purchase_orders WHERE status != 'canceled' GROUP BY po_id, status ), receiving_totals AS ( SELECT po.po_id, SUM(r.qty_each) as total_received FROM receivings r JOIN purchase_orders po ON r.pid = po.pid AND r.sku = po.sku WHERE po.po_id IN (SELECT po_id FROM po_totals) GROUP BY po.po_id ), combined_data AS ( SELECT po.po_id, po.status, po.total_ordered, po.total_cost, COALESCE(r.total_received, 0) as total_received FROM po_totals po LEFT JOIN receiving_totals r ON po.po_id = r.po_id ) SELECT COUNT(DISTINCT po_id) as order_count, SUM(total_ordered) as total_ordered, SUM(total_received) as total_received, ROUND( SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 ) as fulfillment_rate, ROUND(SUM(total_cost)::numeric, 3) as total_value, ROUND(AVG(total_cost)::numeric, 3) as avg_cost, COUNT(DISTINCT CASE WHEN status = 'created' THEN po_id END) as pending_count, COUNT(DISTINCT CASE WHEN status = 'receiving_started' THEN po_id END) as partial_count, COUNT(DISTINCT CASE WHEN status = 'done' THEN po_id END) as completed_count, COUNT(DISTINCT CASE WHEN status = 'canceled' THEN po_id END) as canceled_count FROM combined_data `); // Parse numeric values const parsedStatus = { order_count: Number(status[0]?.order_count) || 0, total_ordered: Number(status[0]?.total_ordered) || 0, total_received: Number(status[0]?.total_received) || 0, fulfillment_rate: Number(status[0]?.fulfillment_rate) || 0, total_value: Number(status[0]?.total_value) || 0, avg_cost: Number(status[0]?.avg_cost) || 0, status_breakdown: { pending: Number(status[0]?.pending_count) || 0, partial: Number(status[0]?.partial_count) || 0, completed: Number(status[0]?.completed_count) || 0, canceled: Number(status[0]?.canceled_count) || 0 } }; res.json(parsedStatus); } catch (error) { console.error('Error fetching receiving status:', error); res.status(500).json({ error: 'Failed to fetch receiving status' }); } }); // Get order vs received quantities by product router.get('/order-vs-received', async (req, res) => { try { const pool = req.app.locals.pool; const { rows: quantities } = await pool.query(` WITH order_data AS ( SELECT p.pid, p.title, p.SKU, SUM(po.ordered) as ordered_quantity, COUNT(DISTINCT po.po_id) as order_count FROM products p JOIN purchase_orders po ON p.pid = po.pid WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days') GROUP BY p.pid, p.title, p.SKU ), receiving_data AS ( SELECT r.pid, SUM(r.qty_each) as received_quantity FROM receivings r JOIN purchase_orders po ON r.receiving_id = po.po_id WHERE r.received_date >= (CURRENT_DATE - INTERVAL '90 days') GROUP BY r.pid ) SELECT o.pid as product_id, o.title as product, o.SKU as sku, o.ordered_quantity, COALESCE(r.received_quantity, 0) as received_quantity, ROUND( COALESCE(r.received_quantity, 0) / NULLIF(o.ordered_quantity, 0) * 100, 1 ) as fulfillment_rate, o.order_count FROM order_data o LEFT JOIN receiving_data r ON o.pid = r.pid WHERE o.order_count > 0 ORDER BY o.ordered_quantity DESC LIMIT 20 `); // Parse numeric values and add id for React keys const parsedQuantities = quantities.map(q => ({ id: q.product_id, ...q, ordered_quantity: Number(q.ordered_quantity) || 0, received_quantity: Number(q.received_quantity) || 0, fulfillment_rate: Number(q.fulfillment_rate) || 0, order_count: Number(q.order_count) || 0 })); res.json(parsedQuantities); } catch (error) { console.error('Error fetching order vs received quantities:', error); res.status(500).json({ error: 'Failed to fetch order vs received quantities' }); } }); // Get purchase order items router.get('/:id/items', async (req, res) => { try { const pool = req.app.locals.pool; const { id } = req.params; if (!id) { return res.status(400).json({ error: 'Purchase order ID is required' }); } // Query to get purchase order items with product details const { rows: items } = await pool.query(` WITH po_items AS ( SELECT po.po_id, po.pid, po.sku, COALESCE(po.name, p.title) as product_name, po.po_cost_price, po.ordered, po.status FROM purchase_orders po LEFT JOIN products p ON po.pid = p.pid WHERE po.po_id = $1 ), receiving_items AS ( SELECT r.receiving_id, r.pid, r.sku, SUM(r.qty_each) as received FROM receivings r WHERE r.receiving_id = $1 GROUP BY r.receiving_id, r.pid, r.sku ) SELECT pi.po_id as id, pi.pid, pi.sku, pi.product_name, p.barcode, pi.po_cost_price, pi.ordered, COALESCE(ri.received, 0) as received, ROUND(pi.ordered * pi.po_cost_price, 2) as total_cost, CASE WHEN ri.received IS NULL THEN 'Not Received' WHEN ri.received = 0 THEN 'Not Received' WHEN ri.received < pi.ordered THEN 'Partially Received' WHEN ri.received >= pi.ordered THEN 'Fully Received' END as receiving_status FROM po_items pi LEFT JOIN receiving_items ri ON pi.pid = ri.pid AND pi.sku = ri.sku LEFT JOIN products p ON pi.pid = p.pid ORDER BY pi.product_name `, [id]); // Parse numeric values const parsedItems = items.map(item => ({ id: `${item.id}_${item.pid}`, pid: item.pid, product_name: item.product_name, sku: item.sku, upc: item.barcode || 'N/A', ordered: Number(item.ordered) || 0, received: Number(item.received) || 0, po_cost_price: Number(item.po_cost_price) || 0, total_cost: Number(item.total_cost) || 0, receiving_status: item.receiving_status })); res.json(parsedItems); } catch (error) { console.error('Error fetching purchase order items:', error); res.status(500).json({ error: 'Failed to fetch purchase order items', details: error.message }); } }); // Get receiving items router.get('/receiving/:id/items', async (req, res) => { try { const pool = req.app.locals.pool; const { id } = req.params; if (!id) { return res.status(400).json({ error: 'Receiving ID is required' }); } // Query to get receiving items with related PO information if available const { rows: items } = await pool.query(` WITH receiving_items AS ( SELECT r.receiving_id, r.pid, r.sku, COALESCE(r.name, p.title) as product_name, r.cost_each, r.qty_each, r.status FROM receivings r LEFT JOIN products p ON r.pid = p.pid WHERE r.receiving_id = $1 ), po_items AS ( SELECT po.po_id, po.pid, po.sku, po.ordered, po.po_cost_price FROM purchase_orders po WHERE po.po_id = $1 ) SELECT ri.receiving_id as id, ri.pid, ri.sku, ri.product_name, p.barcode, COALESCE(po.ordered, 0) as ordered, ri.qty_each as received, COALESCE(po.po_cost_price, ri.cost_each) as po_cost_price, ri.cost_each, ROUND(ri.qty_each * ri.cost_each, 2) as total_cost, CASE WHEN po.ordered IS NULL THEN 'Receiving Only' WHEN ri.qty_each < po.ordered THEN 'Partially Received' WHEN ri.qty_each >= po.ordered THEN 'Fully Received' END as receiving_status FROM receiving_items ri LEFT JOIN po_items po ON ri.pid = po.pid AND ri.sku = po.sku LEFT JOIN products p ON ri.pid = p.pid ORDER BY ri.product_name `, [id]); // Parse numeric values const parsedItems = items.map(item => ({ id: `${item.id}_${item.pid}`, pid: item.pid, product_name: item.product_name, sku: item.sku, upc: item.barcode || 'N/A', ordered: Number(item.ordered) || 0, received: Number(item.received) || 0, po_cost_price: Number(item.po_cost_price) || 0, cost_each: Number(item.cost_each) || 0, qty_each: Number(item.received) || 0, total_cost: Number(item.total_cost) || 0, receiving_status: item.receiving_status })); res.json(parsedItems); } catch (error) { console.error('Error fetching receiving items:', error); res.status(500).json({ error: 'Failed to fetch receiving items', details: error.message }); } }); // New endpoint for delivery metrics router.get('/delivery-metrics', async (req, res) => { try { const pool = req.app.locals.pool; const { rows: deliveryData } = await pool.query(` WITH po_dates AS ( SELECT po_id, date as order_date FROM purchase_orders WHERE status != 'canceled' GROUP BY po_id, date ), receiving_dates AS ( SELECT receiving_id as po_id, MIN(received_date) as first_received_date FROM receivings GROUP BY receiving_id ), delivery_times AS ( SELECT po.po_id, po.order_date, r.first_received_date, CASE WHEN r.first_received_date IS NOT NULL AND po.order_date IS NOT NULL THEN (r.first_received_date::date - po.order_date::date) ELSE NULL END as delivery_days FROM po_dates po JOIN receiving_dates r ON po.po_id = r.po_id WHERE r.first_received_date IS NOT NULL AND po.order_date IS NOT NULL AND r.first_received_date::date >= po.order_date::date ) SELECT ROUND(AVG(delivery_days)::numeric, 1) as avg_delivery_days, ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY delivery_days)::numeric, 1) as median_delivery_days, MIN(delivery_days) as min_delivery_days, MAX(delivery_days) as max_delivery_days, COUNT(*) as total_orders_with_delivery FROM delivery_times WHERE delivery_days >= 0 AND delivery_days <= 365 -- Filter out unreasonable values `); res.json({ avg_delivery_days: Number(deliveryData[0]?.avg_delivery_days) || 0, median_delivery_days: Number(deliveryData[0]?.median_delivery_days) || 0, min_delivery_days: Number(deliveryData[0]?.min_delivery_days) || 0, max_delivery_days: Number(deliveryData[0]?.max_delivery_days) || 0, total_orders_with_delivery: Number(deliveryData[0]?.total_orders_with_delivery) || 0 }); } catch (error) { console.error('Error fetching delivery metrics:', error); res.status(500).json({ error: 'Failed to fetch delivery metrics' }); } }); // PO Pipeline — expected arrivals timeline + overdue summary router.get('/pipeline', async (req, res) => { try { const pool = req.app.locals.pool; // Stale PO filter (reused across queries) const staleFilter = ` WITH stale AS ( SELECT po_id, pid FROM purchase_orders po WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') AND po.expected_date IS NOT NULL AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' AND EXISTS ( SELECT 1 FROM purchase_orders newer WHERE newer.pid = po.pid AND newer.status NOT IN ('canceled', 'done') AND COALESCE(newer.date_ordered, newer.date_created) > COALESCE(po.date_ordered, po.date_created) ) )`; // Expected arrivals by week (excludes stale POs) const { rows: arrivals } = await pool.query(` ${staleFilter} SELECT DATE_TRUNC('week', po.expected_date)::date AS week, COUNT(DISTINCT po.po_id) AS po_count, ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value, COUNT(DISTINCT po.vendor) AS vendor_count FROM purchase_orders po WHERE po.status IN ('ordered', 'electronically_sent') AND po.expected_date IS NOT NULL AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) GROUP BY 1 ORDER BY 1 `); // Overdue POs (excludes stale) const { rows: [overdue] } = await pool.query(` ${staleFilter} SELECT COUNT(DISTINCT po.po_id) AS po_count, ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value FROM purchase_orders po WHERE po.status IN ('ordered', 'electronically_sent') AND po.expected_date IS NOT NULL AND po.expected_date < CURRENT_DATE AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) `); // Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter const { rows: [summary] } = await pool.query(` ${staleFilter} SELECT COUNT(DISTINCT po.po_id) AS total_open_pos, COUNT(DISTINCT po.vendor) AS vendor_count FROM purchase_orders po WHERE po.status IN ('ordered', 'electronically_sent') AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) `); const { rows: [onOrderTotal] } = await pool.query(` SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value FROM product_metrics WHERE is_visible = true `); res.json({ arrivals: arrivals.map(r => ({ week: r.week, poCount: Number(r.po_count) || 0, expectedValue: Number(r.expected_value) || 0, vendorCount: Number(r.vendor_count) || 0, })), overdue: { count: Number(overdue.po_count) || 0, value: Number(overdue.total_value) || 0, }, summary: { totalOpenPOs: Number(summary.total_open_pos) || 0, totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0, vendorCount: Number(summary.vendor_count) || 0, }, }); } catch (error) { console.error('Error fetching PO pipeline:', error); res.status(500).json({ error: 'Failed to fetch PO pipeline' }); } }); module.exports = router;