From 1b9f01d10113e63b382d823b100ea351ef80b3c7 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 1 Apr 2025 12:03:12 -0400 Subject: [PATCH] Add routes for brands, categories, vendors new implementation --- .../src/routes/brandsAggregate.js | 212 ++++++++++++++ .../src/routes/categoriesAggregate.js | 265 ++++++++++++++++++ .../src/routes/vendorsAggregate.js | 216 ++++++++++++++ inventory-server/src/server.js | 6 + inventory-server/src/utils/apiHelpers.js | 35 +++ 5 files changed, 734 insertions(+) create mode 100644 inventory-server/src/routes/brandsAggregate.js create mode 100644 inventory-server/src/routes/categoriesAggregate.js create mode 100644 inventory-server/src/routes/vendorsAggregate.js create mode 100644 inventory-server/src/utils/apiHelpers.js diff --git a/inventory-server/src/routes/brandsAggregate.js b/inventory-server/src/routes/brandsAggregate.js new file mode 100644 index 0000000..5051fd3 --- /dev/null +++ b/inventory-server/src/routes/brandsAggregate.js @@ -0,0 +1,212 @@ +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 brand_metrics +const COLUMN_MAP = { + brandName: { dbCol: 'bm.brand_name', type: 'string' }, + productCount: { dbCol: 'bm.product_count', type: 'number' }, + activeProductCount: { dbCol: 'bm.active_product_count', type: 'number' }, + replenishableProductCount: { dbCol: 'bm.replenishable_product_count', type: 'number' }, + currentStockUnits: { dbCol: 'bm.current_stock_units', type: 'number' }, + currentStockCost: { dbCol: 'bm.current_stock_cost', type: 'number' }, + currentStockRetail: { dbCol: 'bm.current_stock_retail', type: 'number' }, + sales7d: { dbCol: 'bm.sales_7d', type: 'number' }, + revenue7d: { dbCol: 'bm.revenue_7d', type: 'number' }, + sales30d: { dbCol: 'bm.sales_30d', type: 'number' }, + revenue30d: { dbCol: 'bm.revenue_30d', type: 'number' }, + profit30d: { dbCol: 'bm.profit_30d', type: 'number' }, + cogs30d: { dbCol: 'bm.cogs_30d', type: 'number' }, + sales365d: { dbCol: 'bm.sales_365d', type: 'number' }, + revenue365d: { dbCol: 'bm.revenue_365d', type: 'number' }, + lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' }, + lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' }, + avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' }, + // Add aliases if needed + name: { dbCol: 'bm.brand_name', type: 'string' }, +}; + +function getSafeColumnInfo(queryParamKey) { + return COLUMN_MAP[queryParamKey] || null; +} + +// --- Route Handlers --- + +// GET /brands-aggregate/filter-options (Just brands list for now) +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-aggregate/filter-options'); + try { + const { rows: brandRows } = await pool.query(` + SELECT DISTINCT brand_name FROM public.brand_metrics ORDER BY brand_name + `); + res.json({ + brands: brandRows.map(r => r.brand_name), + }); + } catch(error) { + console.error('Error fetching brand filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + +// GET /brands-aggregate/stats (Overall brand stats) +router.get('/stats', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-aggregate/stats'); + try { + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*) AS total_brands, + SUM(active_product_count) AS total_active_products, + SUM(current_stock_cost) AS total_stock_value, + -- Weighted Average Margin + SUM(profit_30d) * 100.0 / NULLIF(SUM(revenue_30d), 0) AS overall_avg_margin_weighted + -- Add other stats + FROM public.brand_metrics bm + `); + + res.json({ + totalBrands: parseInt(stats?.total_brands || 0), + totalActiveProducts: parseInt(stats?.total_active_products || 0), + totalValue: parseFloat(stats?.total_stock_value || 0), + avgMargin: parseFloat(stats?.overall_avg_margin_weighted || 0), + }); + } catch (error) { + console.error('Error fetching brand stats:', error); + res.status(500).json({ error: 'Failed to fetch brand stats.' }); + } +}); + +// GET /brands-aggregate/ (List brands) +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /brands-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 || 'brandName'; // Default sort + const sortColumnInfo = getSafeColumnInfo(sortQueryKey); + const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'bm.brand_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 ')}` : ''; + const baseSql = `FROM public.brand_metrics bm ${whereClause}`; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = `SELECT bm.* ${baseSql} ${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 brands = dataResult.rows; + + // --- Respond --- + res.json({ + brands, + pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, + }); + + } catch (error) { + console.error('Error fetching brand metrics list:', error); + res.status(500).json({ error: 'Failed to fetch brand metrics.' }); + } +}); + +// GET /brands-aggregate/:name (Get single brand metric) +// Implement if needed, remember to URL-decode the name parameter + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/categoriesAggregate.js b/inventory-server/src/routes/categoriesAggregate.js new file mode 100644 index 0000000..adaef2b --- /dev/null +++ b/inventory-server/src/routes/categoriesAggregate.js @@ -0,0 +1,265 @@ +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 category_metrics and categories tables +const COLUMN_MAP = { + categoryId: { dbCol: 'cm.category_id', type: 'integer' }, + categoryName: { dbCol: 'cm.category_name', type: 'string' }, // From aggregate table + categoryType: { dbCol: 'cm.category_type', type: 'integer' }, // From aggregate table + parentId: { dbCol: 'cm.parent_id', type: 'integer' }, // From aggregate table + parentName: { dbCol: 'p.name', type: 'string' }, // Requires JOIN to categories + productCount: { dbCol: 'cm.product_count', type: 'number' }, + activeProductCount: { dbCol: 'cm.active_product_count', type: 'number' }, + replenishableProductCount: { dbCol: 'cm.replenishable_product_count', type: 'number' }, + currentStockUnits: { dbCol: 'cm.current_stock_units', type: 'number' }, + currentStockCost: { dbCol: 'cm.current_stock_cost', type: 'number' }, + currentStockRetail: { dbCol: 'cm.current_stock_retail', type: 'number' }, + sales7d: { dbCol: 'cm.sales_7d', type: 'number' }, + revenue7d: { dbCol: 'cm.revenue_7d', type: 'number' }, + sales30d: { dbCol: 'cm.sales_30d', type: 'number' }, + revenue30d: { dbCol: 'cm.revenue_30d', type: 'number' }, + profit30d: { dbCol: 'cm.profit_30d', type: 'number' }, + cogs30d: { dbCol: 'cm.cogs_30d', type: 'number' }, + sales365d: { dbCol: 'cm.sales_365d', type: 'number' }, + revenue365d: { dbCol: 'cm.revenue_365d', type: 'number' }, + lifetimeSales: { dbCol: 'cm.lifetime_sales', type: 'number' }, + lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' }, + avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' }, + stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' }, + // Add 'status' if filtering by category status needed (requires JOIN) + // status: { dbCol: 'c.status', type: 'string' }, +}; + +function getSafeColumnInfo(queryParamKey) { + return COLUMN_MAP[queryParamKey] || null; +} + +// Type Labels (Consider moving to a shared config or fetching from DB) +const TYPE_LABELS = { + 10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory', + 1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist', // From old schema comments + // Add other types if needed +}; + +// --- Route Handlers --- + +// GET /categories-aggregate/filter-options +router.get('/filter-options', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-aggregate/filter-options'); + try { + // Fetch distinct types directly from the aggregate table if reliable + // Or join with categories table if source of truth is needed + const { rows: typeRows } = await pool.query(` + SELECT DISTINCT category_type + FROM public.category_metrics + ORDER BY category_type + `); + + const typeOptions = typeRows.map(r => ({ + value: r.category_type, + label: TYPE_LABELS[r.category_type] || `Type ${r.category_type}` // Add labels + })); + + // Add other filter options like status if needed + // const { rows: statusRows } = await pool.query(`SELECT DISTINCT status FROM public.categories ORDER BY status`); + + res.json({ + types: typeOptions, + // statuses: statusRows.map(r => r.status) + }); + } catch (error) { + console.error('Error fetching category filter options:', error); + res.status(500).json({ error: 'Failed to fetch filter options' }); + } +}); + +// GET /categories-aggregate/stats +router.get('/stats', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-aggregate/stats'); + try { + // Calculate stats directly from the aggregate table + const { rows: [stats] } = await pool.query(` + SELECT + COUNT(*) AS total_categories, + -- Count active based on the source categories table status + COUNT(CASE WHEN c.status = 'active' THEN cm.category_id END) AS active_categories, + SUM(cm.active_product_count) AS total_active_products, -- Sum from aggregates + SUM(cm.current_stock_cost) AS total_stock_value, -- Sum from aggregates + -- Weighted Average Margin (Revenue as weight) + SUM(cm.profit_30d) * 100.0 / NULLIF(SUM(cm.revenue_30d), 0) AS overall_avg_margin_weighted, + -- Simple Average Margin (less accurate if categories vary greatly in size) + AVG(NULLIF(cm.avg_margin_30d, 0)) AS overall_avg_margin_simple + -- Add SUM(revenue_30d) / SUM(revenue_30d_previous) for growth if needed + FROM public.category_metrics cm + JOIN public.categories c ON cm.category_id = c.cat_id -- Join to check category status + `); + + res.json({ + totalCategories: parseInt(stats?.total_categories || 0), + activeCategories: parseInt(stats?.active_categories || 0), // Based on categories.status + totalActiveProducts: parseInt(stats?.total_active_products || 0), + totalValue: parseFloat(stats?.total_stock_value || 0), + // Choose which avg margin calculation to expose + avgMargin: parseFloat(stats?.overall_avg_margin_weighted || stats?.overall_avg_margin_simple || 0), + // avgGrowth: ... // Calculate if needed + }); + } catch (error) { + console.error('Error fetching category stats:', error); + res.status(500).json({ error: 'Failed to fetch category stats.' }); + } +}); + +// GET /categories-aggregate/ (List categories) +router.get('/', async (req, res) => { + const pool = req.app.locals.pool; + console.log('GET /categories-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 || 'categoryName'; + const sortColumnInfo = getSafeColumnInfo(sortQueryKey); + // Default sort order: Type then Name + const defaultSortOrder = 'ORDER BY cm.category_type ASC, cm.category_name ASC'; + let sortClause = defaultSortOrder; + if (sortColumnInfo) { + const sortColumn = sortColumnInfo.dbCol; + const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); + sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`; + } + + // --- Filtering --- + const conditions = []; + const params = []; + let paramCounter = 1; + + // Add filters 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]; + } + + // Special case for parentName requires join + const requiresJoin = filterKey === 'parentName'; + const columnInfo = getSafeColumnInfo(filterKey); + + if (columnInfo) { + const dbColumn = columnInfo.dbCol; + const valueType = columnInfo.type; + try { + let conditionFragment = ''; + let needsParam = true; + switch (operator.toLowerCase()) { + 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 where needsParam is false + conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push + } + + + if (conditionFragment) { + conditions.push(`(${conditionFragment})`); + } + } catch (parseError) { + console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); + if (needsParam) paramCounter--; // Roll back counter if param push failed + } + } else { + console.warn(`Invalid filter key ignored: ${key}`); + } + } + + // --- Execute Queries --- + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Need JOIN for parent_name if sorting/filtering by it, or always include for display + const needParentJoin = sortColumn === 'p.name' || conditions.some(c => c.includes('p.name')); + + const baseSql = ` + FROM public.category_metrics cm + ${needParentJoin ? 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id' : ''} + ${whereClause} + `; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = ` + SELECT cm.* ${needParentJoin ? ', p.name as parent_name' : ''} + ${baseSql} + ${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 categories = dataResult.rows; + + // --- Respond --- + res.json({ + categories, + pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit }, + }); + + } catch (error) { + console.error('Error fetching category metrics list:', error); + res.status(500).json({ error: 'Failed to fetch category metrics.' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/vendorsAggregate.js b/inventory-server/src/routes/vendorsAggregate.js new file mode 100644 index 0000000..dda2565 --- /dev/null +++ b/inventory-server/src/routes/vendorsAggregate.js @@ -0,0 +1,216 @@ +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' }, + // Add aliases if needed for frontend compatibility + name: { dbCol: 'vm.vendor_name', type: 'string' }, + leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, +}; + +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 { + const { rows: vendorRows } = await pool.query(` + SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name + `); + res.json({ + vendors: vendorRows.map(r => r.vendor_name), + }); + } 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 { + 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 -- Simple average + -- Add more overall stats: weighted margin, total POs etc. + FROM public.vendor_metrics vm + `); + + res.json({ + totalVendors: parseInt(stats?.total_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) + }); + } 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 ')}` : ''; + const baseSql = `FROM public.vendor_metrics vm ${whereClause}`; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const dataSql = `SELECT vm.* ${baseSql} ${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; + + // --- 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; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index e9ad7cb..ca5bd0f 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -20,6 +20,9 @@ const aiValidationRouter = require('./routes/ai-validation'); const templatesRouter = require('./routes/templates'); const aiPromptsRouter = require('./routes/ai-prompts'); const reusableImagesRouter = require('./routes/reusable-images'); +const categoriesAggregateRouter = require('./routes/categoriesAggregate'); +const vendorsAggregateRouter = require('./routes/vendorsAggregate'); +const brandsAggregateRouter = require('./routes/brandsAggregate'); // Get the absolute path to the .env file const envPath = '/var/www/html/inventory/.env'; @@ -107,6 +110,9 @@ async function startServer() { app.use('/api/templates', templatesRouter); app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/reusable-images', reusableImagesRouter); + app.use('/api/categories-aggregate', categoriesAggregateRouter); + app.use('/api/vendors-aggregate', vendorsAggregateRouter); + app.use('/api/brands-aggregate', brandsAggregateRouter); // Basic health check route app.get('/health', (req, res) => { diff --git a/inventory-server/src/utils/apiHelpers.js b/inventory-server/src/utils/apiHelpers.js new file mode 100644 index 0000000..7ebdd4a --- /dev/null +++ b/inventory-server/src/utils/apiHelpers.js @@ -0,0 +1,35 @@ +/** + * Parses a query parameter value based on its expected type. + * Throws error for invalid formats. Adjust date handling as needed. + */ +function parseValue(value, type) { + if (value === null || value === undefined || value === '') return null; + + switch (type) { + case 'number': + const num = parseFloat(value); + if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`); + return num; + case 'integer': // Specific type for integer IDs etc. + const int = parseInt(value, 10); + if (isNaN(int)) throw new Error(`Invalid integer format: "${value}"`); + return int; + case 'boolean': + if (String(value).toLowerCase() === 'true') return true; + if (String(value).toLowerCase() === 'false') return false; + throw new Error(`Invalid boolean format: "${value}"`); + case 'date': + // Basic ISO date format validation (YYYY-MM-DD) + if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) { + console.warn(`Potentially invalid date format passed: "${value}"`); + // Optionally throw an error or return null depending on strictness + // throw new Error(`Invalid date format (YYYY-MM-DD expected): "${value}"`); + } + return String(value); // Send as string, let DB handle casting/comparison + case 'string': + default: + return String(value); + } +} + +module.exports = { parseValue }; \ No newline at end of file