const express = require('express'); const router = express.Router(); const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed // --- Configuration & Helpers --- const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 200; // Maps query keys to DB columns in vendor_metrics const COLUMN_MAP = { vendorName: { dbCol: 'vm.vendor_name', type: 'string' }, productCount: { dbCol: 'vm.product_count', type: 'number' }, activeProductCount: { dbCol: 'vm.active_product_count', type: 'number' }, replenishableProductCount: { dbCol: 'vm.replenishable_product_count', type: 'number' }, currentStockUnits: { dbCol: 'vm.current_stock_units', type: 'number' }, currentStockCost: { dbCol: 'vm.current_stock_cost', type: 'number' }, currentStockRetail: { dbCol: 'vm.current_stock_retail', type: 'number' }, onOrderUnits: { dbCol: 'vm.on_order_units', type: 'number' }, onOrderCost: { dbCol: 'vm.on_order_cost', type: 'number' }, poCount365d: { dbCol: 'vm.po_count_365d', type: 'number' }, avgLeadTimeDays: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, sales7d: { dbCol: 'vm.sales_7d', type: 'number' }, revenue7d: { dbCol: 'vm.revenue_7d', type: 'number' }, sales30d: { dbCol: 'vm.sales_30d', type: 'number' }, revenue30d: { dbCol: 'vm.revenue_30d', type: 'number' }, profit30d: { dbCol: 'vm.profit_30d', type: 'number' }, cogs30d: { dbCol: 'vm.cogs_30d', type: 'number' }, sales365d: { dbCol: 'vm.sales_365d', type: 'number' }, revenue365d: { dbCol: 'vm.revenue_365d', type: 'number' }, lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' }, lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' }, avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' }, // Growth metrics salesGrowth30dVsPrev: { dbCol: 'vm.sales_growth_30d_vs_prev', type: 'number' }, revenueGrowth30dVsPrev: { dbCol: 'vm.revenue_growth_30d_vs_prev', type: 'number' }, // Add aliases if needed for frontend compatibility name: { dbCol: 'vm.vendor_name', type: 'string' }, leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, // Add status for filtering status: { dbCol: 'vendor_status', type: 'string' }, }; function getSafeColumnInfo(queryParamKey) { return COLUMN_MAP[queryParamKey] || null; } // --- Route Handlers --- // GET /vendors-aggregate/filter-options (Just vendors list for now) router.get('/filter-options', async (req, res) => { const pool = req.app.locals.pool; console.log('GET /vendors-aggregate/filter-options'); try { // Get vendor names const { rows: vendorRows } = await pool.query(` SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name `); // Get status values - calculate them since they're derived const { rows: statusRows } = await pool.query(` SELECT DISTINCT CASE WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active' WHEN po_count_365d > 0 THEN 'inactive' ELSE 'pending' END as status FROM public.vendor_metrics ORDER BY status `); res.json({ vendors: vendorRows.map(r => r.vendor_name), statuses: statusRows.map(r => r.status) }); } catch(error) { console.error('Error fetching vendor filter options:', error); res.status(500).json({ error: 'Failed to fetch filter options' }); } }); // GET /vendors-aggregate/stats (Overall vendor stats) router.get('/stats', async (req, res) => { const pool = req.app.locals.pool; console.log('GET /vendors-aggregate/stats'); try { // Get basic vendor stats from aggregate table const { rows: [stats] } = await pool.query(` SELECT COUNT(*) AS total_vendors, SUM(active_product_count) AS total_active_products, SUM(current_stock_cost) AS total_stock_value, SUM(on_order_cost) AS total_on_order_value, AVG(NULLIF(avg_lead_time_days, 0)) AS overall_avg_lead_time FROM public.vendor_metrics vm `); // Count active vendors based on criteria (from old vendors.js) const { rows: [activeStats] } = await pool.query(` SELECT COUNT(DISTINCT CASE WHEN po_count_365d > 0 THEN vendor_name END) as active_vendors FROM public.vendor_metrics `); // Get overall cost metrics from purchase orders const { rows: [overallCostMetrics] } = await pool.query(` SELECT 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 FROM purchase_orders WHERE po_cost_price IS NOT NULL AND ordered > 0 AND vendor IS NOT NULL AND vendor != '' `); res.json({ totalVendors: parseInt(stats?.total_vendors || 0), activeVendors: parseInt(activeStats?.active_vendors || 0), totalActiveProducts: parseInt(stats?.total_active_products || 0), totalValue: parseFloat(stats?.total_stock_value || 0), totalOnOrderValue: parseFloat(stats?.total_on_order_value || 0), avgLeadTime: parseFloat(stats?.overall_avg_lead_time || 0), avgUnitCost: parseFloat(overallCostMetrics?.avg_unit_cost || 0), totalSpend: parseFloat(overallCostMetrics?.total_spend || 0) }); } catch (error) { console.error('Error fetching vendor stats:', error); res.status(500).json({ error: 'Failed to fetch vendor stats.' }); } }); // GET /vendors-aggregate/ (List vendors) router.get('/', async (req, res) => { const pool = req.app.locals.pool; console.log('GET /vendors-aggregate received query:', req.query); try { // --- Pagination --- let page = parseInt(req.query.page, 10) || 1; let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT; limit = Math.min(limit, MAX_PAGE_LIMIT); const offset = (page - 1) * limit; // --- Sorting --- const sortQueryKey = req.query.sort || 'vendorName'; // Default sort const sortColumnInfo = getSafeColumnInfo(sortQueryKey); const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'vm.vendor_name'; const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`; // --- Filtering --- const conditions = []; const params = []; let paramCounter = 1; // Build conditions based on req.query, using COLUMN_MAP and parseValue for (const key in req.query) { if (['page', 'limit', 'sort', 'order'].includes(key)) continue; let filterKey = key; let operator = '='; // Default operator const value = req.query[key]; const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); if (operatorMatch) { filterKey = operatorMatch[1]; operator = operatorMatch[2]; } const columnInfo = getSafeColumnInfo(filterKey); if (columnInfo) { const dbColumn = columnInfo.dbCol; const valueType = columnInfo.type; try { let conditionFragment = ''; let needsParam = true; switch (operator.toLowerCase()) { // Normalize operator case 'eq': operator = '='; break; case 'ne': operator = '<>'; break; case 'gt': operator = '>'; break; case 'gte': operator = '>='; break; case 'lt': operator = '<'; break; case 'lte': operator = '<='; break; case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break; case 'between': const [val1, val2] = String(value).split(','); if (val1 !== undefined && val2 !== undefined) { conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; params.push(parseValue(val1, valueType), parseValue(val2, valueType)); needsParam = false; } else continue; break; case 'in': const inValues = String(value).split(','); if (inValues.length > 0) { const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); conditionFragment = `${dbColumn} IN (${placeholders})`; params.push(...inValues.map(v => parseValue(v, valueType))); needsParam = false; } else continue; break; default: operator = '='; break; } if (needsParam) { conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; params.push(parseValue(value, valueType)); } else if (!conditionFragment) { // For LIKE/ILIKE conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; } if (conditionFragment) { conditions.push(`(${conditionFragment})`); } } catch (parseError) { console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); if (needsParam) paramCounter--; } } else { console.warn(`Invalid filter key ignored: ${key}`); } } // --- Execute Queries --- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Status calculation from vendors.js const statusCase = ` CASE WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active' WHEN po_count_365d > 0 THEN 'inactive' ELSE 'pending' END as vendor_status `; const baseSql = ` FROM ( SELECT vm.*, ${statusCase} FROM public.vendor_metrics vm ) vm ${whereClause} `; const countSql = `SELECT COUNT(*) AS total ${baseSql}`; const dataSql = ` WITH vendor_data AS ( SELECT vm.*, ${statusCase} FROM public.vendor_metrics vm ) SELECT vm.*, COALESCE(po.avg_unit_cost, 0) as avg_unit_cost, COALESCE(po.total_spend, 0) as total_spend FROM vendor_data vm LEFT JOIN ( SELECT vendor, 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 FROM purchase_orders WHERE po_cost_price IS NOT NULL AND ordered > 0 GROUP BY vendor ) po ON vm.vendor_name = po.vendor ${whereClause} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1} `; const dataParams = [...params, limit, offset]; console.log("Count SQL:", countSql, params); console.log("Data SQL:", dataSql, dataParams); const [countResult, dataResult] = await Promise.all([ pool.query(countSql, params), pool.query(dataSql, dataParams) ]); const total = parseInt(countResult.rows[0].total, 10); const vendors = dataResult.rows.map(row => { // Create a new object with both snake_case and camelCase keys const transformedRow = { ...row }; // Start with original data for (const key in row) { // Skip null/undefined values if (row[key] === null || row[key] === undefined) { continue; // Original already has the null value } // Transform keys to match frontend expectations (add camelCase versions) // First handle cases like sales_7d -> sales7d let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); // Then handle regular snake_case -> camelCase camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); if (camelKey !== key) { // Only add if different from original transformedRow[camelKey] = row[key]; } } return transformedRow; }); // --- Respond --- res.json({ vendors, pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, }); } catch (error) { console.error('Error fetching vendor metrics list:', error); res.status(500).json({ error: 'Failed to fetch vendor metrics.' }); } }); // GET /vendors-aggregate/:name (Get single vendor metric) // Implement if needed, remember to URL-decode the name parameter module.exports = router;