Enhance product page filtering

This commit is contained in:
2025-01-13 12:11:51 -05:00
parent dd882490c8
commit fffc0e759c
4 changed files with 556 additions and 290 deletions

View File

@@ -13,12 +13,6 @@ router.get('/', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const search = req.query.search || '';
const category = req.query.category || 'all';
const vendor = req.query.vendor || 'all';
const stockStatus = req.query.stockStatus || 'all';
const minPrice = parseFloat(req.query.minPrice) || 0;
const maxPrice = req.query.maxPrice ? parseFloat(req.query.maxPrice) : null;
const sortColumn = req.query.sortColumn || 'title';
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
@@ -26,12 +20,19 @@ router.get('/', async (req, res) => {
const conditions = ['p.visible = true'];
const params = [];
if (search) {
// Handle text search filters
if (req.query.search) {
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
params.push(`%${req.query.search}%`, `%${req.query.search}%`);
}
if (category !== 'all') {
if (req.query.sku) {
conditions.push('p.SKU LIKE ?');
params.push(`%${req.query.sku}%`);
}
// Handle select filters
if (req.query.category && req.query.category !== 'all') {
conditions.push(`
p.product_id IN (
SELECT pc.product_id
@@ -40,99 +41,144 @@ router.get('/', async (req, res) => {
WHERE c.name = ?
)
`);
params.push(category);
params.push(req.query.category);
}
if (vendor !== 'all') {
if (req.query.vendor && req.query.vendor !== 'all') {
conditions.push('p.vendor = ?');
params.push(vendor);
params.push(req.query.vendor);
}
if (stockStatus !== 'all') {
switch (stockStatus) {
case 'out_of_stock':
conditions.push('p.stock_quantity = 0');
break;
case 'low_stock':
conditions.push('p.stock_quantity > 0 AND p.stock_quantity <= 5');
break;
case 'in_stock':
conditions.push('p.stock_quantity > 5');
break;
}
if (req.query.brand && req.query.brand !== 'all') {
conditions.push('p.brand = ?');
params.push(req.query.brand);
}
if (minPrice > 0) {
if (req.query.abcClass) {
conditions.push('pm.abc_class = ?');
params.push(req.query.abcClass);
}
// Handle numeric range filters
if (req.query.minStock) {
conditions.push('p.stock_quantity >= ?');
params.push(parseFloat(req.query.minStock));
}
if (req.query.maxStock) {
conditions.push('p.stock_quantity <= ?');
params.push(parseFloat(req.query.maxStock));
}
if (req.query.minPrice) {
conditions.push('p.price >= ?');
params.push(minPrice);
params.push(parseFloat(req.query.minPrice));
}
if (maxPrice) {
if (req.query.maxPrice) {
conditions.push('p.price <= ?');
params.push(maxPrice);
params.push(parseFloat(req.query.maxPrice));
}
if (req.query.minSalesAvg) {
conditions.push('pm.daily_sales_avg >= ?');
params.push(parseFloat(req.query.minSalesAvg));
}
if (req.query.maxSalesAvg) {
conditions.push('pm.daily_sales_avg <= ?');
params.push(parseFloat(req.query.maxSalesAvg));
}
if (req.query.minMargin) {
conditions.push('pm.avg_margin_percent >= ?');
params.push(parseFloat(req.query.minMargin));
}
if (req.query.maxMargin) {
conditions.push('pm.avg_margin_percent <= ?');
params.push(parseFloat(req.query.maxMargin));
}
if (req.query.minGMROI) {
conditions.push('pm.gmroi >= ?');
params.push(parseFloat(req.query.minGMROI));
}
if (req.query.maxGMROI) {
conditions.push('pm.gmroi <= ?');
params.push(parseFloat(req.query.maxGMROI));
}
// Handle status filters
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
conditions.push('pm.stock_status = ?');
params.push(req.query.stockStatus);
}
// Get total count for pagination
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM products p WHERE ${conditions.join(' AND ')}`,
`SELECT COUNT(DISTINCT p.product_id) as total
FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE ${conditions.join(' AND ')}`,
params
);
const total = countResult[0].total;
// Get paginated results with metrics
// Get available filters
const [categories] = await pool.query(
'SELECT name FROM categories ORDER BY name'
);
const [vendors] = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
);
// Main query with all fields
const query = `
WITH product_thresholds AS (
SELECT
p.product_id,
COALESCE(
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor = p.vendor LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor IS NULL LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
90
) as target_days
FROM products p
)
SELECT
p.product_id,
p.title,
p.SKU,
p.stock_quantity,
p.price,
p.regular_price,
p.cost_price,
p.landing_cost_price,
p.barcode,
p.vendor,
p.vendor_reference,
p.brand,
p.visible,
p.managing_stock,
p.replenishable,
p.moq,
p.uom,
p.image,
p.*,
GROUP_CONCAT(DISTINCT c.name) as categories,
-- Metrics from product_metrics
pm.daily_sales_avg,
pm.weekly_sales_avg,
pm.monthly_sales_avg,
pm.avg_quantity_per_order,
pm.number_of_orders,
pm.first_sale_date,
pm.last_sale_date,
pm.days_of_inventory,
pm.weeks_of_inventory,
pm.reorder_point,
pm.safety_stock,
pm.avg_margin_percent,
pm.total_revenue,
pm.inventory_value,
pm.cost_of_goods_sold,
pm.gross_profit,
pm.gmroi,
pm.avg_lead_time_days,
pm.last_purchase_date,
pm.last_received_date,
pm.abc_class,
pm.stock_status,
pm.turnover_rate,
pm.avg_lead_time_days,
pm.current_lead_time,
pm.target_lead_time,
pm.lead_time_status
pm.lead_time_status,
pm.days_of_inventory as days_of_stock,
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id
WHERE ${conditions.join(' AND ')}
GROUP BY p.product_id
ORDER BY ${sortColumn} ${sortDirection}
@@ -141,64 +187,40 @@ router.get('/', async (req, res) => {
const [rows] = await pool.query(query, [...params, limit, offset]);
// Transform the categories string into an array and parse numeric values
const productsWithCategories = rows.map(product => ({
...product,
categories: product.categories ? [...new Set(product.categories.split(','))] : [],
// Parse numeric values
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
landing_cost_price: parseFloat(product.landing_cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0,
moq: parseInt(product.moq) || 1,
uom: parseInt(product.uom) || 1,
// Parse metrics
daily_sales_avg: parseFloat(product.daily_sales_avg) || null,
weekly_sales_avg: parseFloat(product.weekly_sales_avg) || null,
monthly_sales_avg: parseFloat(product.monthly_sales_avg) || null,
avg_quantity_per_order: parseFloat(product.avg_quantity_per_order) || null,
number_of_orders: parseInt(product.number_of_orders) || null,
days_of_inventory: parseInt(product.days_of_inventory) || null,
weeks_of_inventory: parseInt(product.weeks_of_inventory) || null,
reorder_point: parseInt(product.reorder_point) || null,
safety_stock: parseInt(product.safety_stock) || null,
avg_margin_percent: parseFloat(product.avg_margin_percent) || null,
total_revenue: parseFloat(product.total_revenue) || null,
inventory_value: parseFloat(product.inventory_value) || null,
cost_of_goods_sold: parseFloat(product.cost_of_goods_sold) || null,
gross_profit: parseFloat(product.gross_profit) || null,
gmroi: parseFloat(product.gmroi) || null,
turnover_rate: parseFloat(product.turnover_rate) || null,
avg_lead_time_days: parseInt(product.avg_lead_time_days) || null,
current_lead_time: parseInt(product.current_lead_time) || null,
target_lead_time: parseInt(product.target_lead_time) || null
// Transform the results
const products = rows.map(row => ({
...row,
categories: row.categories ? row.categories.split(',') : [],
price: parseFloat(row.price),
cost_price: parseFloat(row.cost_price),
landing_cost_price: parseFloat(row.landing_cost_price),
stock_quantity: parseInt(row.stock_quantity),
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
gmroi: parseFloat(row.gmroi) || 0,
lead_time_days: parseInt(row.lead_time_days) || 0,
days_of_stock: parseFloat(row.days_of_stock) || 0,
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0
}));
// Get unique categories and vendors for filters
const [categories] = await pool.query(
'SELECT name FROM categories ORDER BY name'
);
const [vendors] = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
);
res.json({
products: productsWithCategories,
products,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
currentPage: page,
limit
totalPages: Math.ceil(total / limit)
},
filters: {
categories: categories.map(c => c.name),
vendors: vendors.map(v => v.vendor)
categories: categories.map(category => category.name),
vendors: vendors.map(vendor => vendor.vendor)
}
});
} catch (error) {
console.error('Error fetching products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
res.status(500).json({ error: 'Internal server error' });
}
});