323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
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; |