Add routes for brands, categories, vendors new implementation
This commit is contained in:
212
inventory-server/src/routes/brandsAggregate.js
Normal file
212
inventory-server/src/routes/brandsAggregate.js
Normal file
@@ -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;
|
||||||
265
inventory-server/src/routes/categoriesAggregate.js
Normal file
265
inventory-server/src/routes/categoriesAggregate.js
Normal file
@@ -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;
|
||||||
216
inventory-server/src/routes/vendorsAggregate.js
Normal file
216
inventory-server/src/routes/vendorsAggregate.js
Normal file
@@ -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;
|
||||||
@@ -20,6 +20,9 @@ const aiValidationRouter = require('./routes/ai-validation');
|
|||||||
const templatesRouter = require('./routes/templates');
|
const templatesRouter = require('./routes/templates');
|
||||||
const aiPromptsRouter = require('./routes/ai-prompts');
|
const aiPromptsRouter = require('./routes/ai-prompts');
|
||||||
const reusableImagesRouter = require('./routes/reusable-images');
|
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
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -107,6 +110,9 @@ async function startServer() {
|
|||||||
app.use('/api/templates', templatesRouter);
|
app.use('/api/templates', templatesRouter);
|
||||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
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
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
35
inventory-server/src/utils/apiHelpers.js
Normal file
35
inventory-server/src/utils/apiHelpers.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user