Updates and fixes for products page
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Pool } = require('pg'); // Assuming pg driver
|
||||
|
||||
// --- Configuration & Helpers ---
|
||||
|
||||
@@ -255,32 +254,98 @@ const SPECIAL_SORT_COLUMNS = {
|
||||
};
|
||||
|
||||
// Status priority for sorting (lower number = higher priority)
|
||||
// Values must match what's stored in the DB status column
|
||||
const STATUS_PRIORITY = {
|
||||
'Critical': 1,
|
||||
'At Risk': 2,
|
||||
'Reorder': 3,
|
||||
'Overstocked': 4,
|
||||
'Reorder Soon': 3,
|
||||
'Overstock': 4,
|
||||
'Healthy': 5,
|
||||
'New': 6
|
||||
// Any other status will be sorted alphabetically after these
|
||||
};
|
||||
|
||||
// Get database column name from frontend column name
|
||||
// Returns null for unknown keys so callers can skip them
|
||||
function getDbColumn(frontendColumn) {
|
||||
return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found
|
||||
return COLUMN_MAP[frontendColumn] || null;
|
||||
}
|
||||
|
||||
// Get column type for proper sorting
|
||||
// Get column type by searching through the COLUMN_TYPES arrays
|
||||
function getColumnType(frontendColumn) {
|
||||
return COLUMN_TYPES[frontendColumn] || 'string';
|
||||
if (COLUMN_TYPES.numeric.includes(frontendColumn)) return 'numeric';
|
||||
if (COLUMN_TYPES.date.includes(frontendColumn)) return 'date';
|
||||
if (COLUMN_TYPES.boolean.includes(frontendColumn)) return 'boolean';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
// GET /metrics/summary - Aggregate KPI summary for the current view
|
||||
router.get('/summary', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
try {
|
||||
// Build WHERE clause from same filters as main list endpoint
|
||||
const conditions = ['pm.is_visible = true', 'pm.is_replenishable = true'];
|
||||
const params = [];
|
||||
let paramCounter = 1;
|
||||
|
||||
// Handle showNonReplenishable
|
||||
if (req.query.showNonReplenishable === 'true') {
|
||||
// Remove the is_replenishable condition
|
||||
conditions.pop();
|
||||
}
|
||||
// Handle showInvisible
|
||||
if (req.query.showInvisible === 'true') {
|
||||
conditions.shift(); // Remove is_visible condition
|
||||
}
|
||||
|
||||
// Handle stock_status filter
|
||||
if (req.query.stock_status) {
|
||||
conditions.push(`pm.status = $${paramCounter++}`);
|
||||
params.push(req.query.stock_status);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*)::int AS total_products,
|
||||
COALESCE(SUM(pm.current_stock_cost), 0)::numeric(15,2) AS total_stock_value,
|
||||
COALESCE(SUM(pm.current_stock_retail), 0)::numeric(15,2) AS total_stock_retail,
|
||||
COUNT(*) FILTER (WHERE pm.status IN ('Critical', 'Reorder Soon'))::int AS needs_reorder_count,
|
||||
COALESCE(SUM(pm.replenishment_cost) FILTER (WHERE pm.replenishment_units > 0), 0)::numeric(15,2) AS total_replenishment_cost,
|
||||
COALESCE(SUM(pm.replenishment_units) FILTER (WHERE pm.replenishment_units > 0), 0)::int AS total_replenishment_units,
|
||||
COALESCE(SUM(pm.overstocked_cost) FILTER (WHERE pm.overstocked_units > 0), 0)::numeric(15,2) AS total_overstock_value,
|
||||
COALESCE(SUM(pm.overstocked_units) FILTER (WHERE pm.overstocked_units > 0), 0)::int AS total_overstock_units,
|
||||
COALESCE(SUM(pm.on_order_qty), 0)::int AS total_on_order_units,
|
||||
COALESCE(SUM(pm.on_order_cost), 0)::numeric(15,2) AS total_on_order_cost,
|
||||
COALESCE(AVG(pm.stock_cover_in_days) FILTER (WHERE pm.stock_cover_in_days IS NOT NULL AND pm.current_stock > 0), 0)::numeric(10,1) AS avg_stock_cover_days,
|
||||
COUNT(*) FILTER (WHERE pm.current_stock = 0)::int AS out_of_stock_count,
|
||||
COALESCE(SUM(pm.forecast_lost_revenue) FILTER (WHERE pm.forecast_lost_revenue > 0), 0)::numeric(15,2) AS total_lost_revenue,
|
||||
COALESCE(SUM(pm.forecast_lost_sales_units) FILTER (WHERE pm.forecast_lost_sales_units > 0), 0)::int AS total_lost_sales_units,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'Critical')::int AS critical_count,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'Reorder Soon')::int AS reorder_count,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'At Risk')::int AS at_risk_count,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'Overstock')::int AS overstock_count,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'Healthy')::int AS healthy_count,
|
||||
COUNT(*) FILTER (WHERE pm.status = 'New')::int AS new_count
|
||||
FROM public.product_metrics pm
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching metrics summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch metrics summary.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /metrics/filter-options - Provide distinct values for filter dropdowns
|
||||
router.get('/filter-options', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
console.log('GET /metrics/filter-options');
|
||||
try {
|
||||
const [vendorRes, brandRes, abcClassRes] = await Promise.all([
|
||||
pool.query(`SELECT DISTINCT vendor FROM public.product_metrics WHERE vendor IS NOT NULL AND vendor <> '' ORDER BY vendor`),
|
||||
@@ -304,7 +369,6 @@ router.get('/filter-options', async (req, res) => {
|
||||
// GET /metrics/ - List all product metrics with filtering, sorting, pagination
|
||||
router.get('/', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
console.log('GET /metrics received query:', req.query);
|
||||
|
||||
try {
|
||||
// --- Pagination ---
|
||||
@@ -317,11 +381,9 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// --- Sorting ---
|
||||
const sortQueryKey = req.query.sort || 'title'; // Default sort field key
|
||||
const dbColumn = getDbColumn(sortQueryKey);
|
||||
const sortDbColumn = getDbColumn(sortQueryKey) || 'pm.title';
|
||||
const columnType = getColumnType(sortQueryKey);
|
||||
|
||||
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
|
||||
|
||||
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
// Always put nulls last regardless of sort direction or column type
|
||||
@@ -332,29 +394,29 @@ router.get('/', async (req, res) => {
|
||||
|
||||
if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') {
|
||||
// Sort by absolute value for columns where negative values matter
|
||||
orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
|
||||
} else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
|
||||
orderByClause = `ABS(${sortDbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
|
||||
} else if (columnType === 'numeric' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
|
||||
// For numeric columns, cast to numeric to ensure proper sorting
|
||||
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`;
|
||||
orderByClause = `${sortDbColumn}::numeric ${sortDirection} ${nullsOrder}`;
|
||||
} else if (columnType === 'date') {
|
||||
// For date columns, cast to timestamp to ensure proper sorting
|
||||
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`;
|
||||
} else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
|
||||
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn}::timestamp ${sortDirection}`;
|
||||
} else if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
|
||||
// Special handling for status column, using priority for known statuses
|
||||
orderByClause = `
|
||||
CASE WHEN ${dbColumn} IS NULL THEN 999
|
||||
WHEN ${dbColumn} = 'Critical' THEN 1
|
||||
WHEN ${dbColumn} = 'At Risk' THEN 2
|
||||
WHEN ${dbColumn} = 'Reorder' THEN 3
|
||||
WHEN ${dbColumn} = 'Overstocked' THEN 4
|
||||
WHEN ${dbColumn} = 'Healthy' THEN 5
|
||||
WHEN ${dbColumn} = 'New' THEN 6
|
||||
CASE WHEN ${sortDbColumn} IS NULL THEN 999
|
||||
WHEN ${sortDbColumn} = 'Critical' THEN 1
|
||||
WHEN ${sortDbColumn} = 'At Risk' THEN 2
|
||||
WHEN ${sortDbColumn} = 'Reorder Soon' THEN 3
|
||||
WHEN ${sortDbColumn} = 'Overstock' THEN 4
|
||||
WHEN ${sortDbColumn} = 'Healthy' THEN 5
|
||||
WHEN ${sortDbColumn} = 'New' THEN 6
|
||||
ELSE 100
|
||||
END ${sortDirection} ${nullsOrder},
|
||||
${dbColumn} ${sortDirection}`;
|
||||
${sortDbColumn} ${sortDirection}`;
|
||||
} else {
|
||||
// For string and boolean columns, no special casting needed
|
||||
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`;
|
||||
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn} ${sortDirection}`;
|
||||
}
|
||||
|
||||
// --- Filtering ---
|
||||
@@ -389,26 +451,26 @@ router.get('/', async (req, res) => {
|
||||
let operator = '='; // Default operator
|
||||
let value = req.query[key];
|
||||
|
||||
// Check for operator suffixes (e.g., sales30d_gt, title_like)
|
||||
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
|
||||
// Check for operator suffixes (e.g., sales30d_gt, title_ilike, isVisible_is_true)
|
||||
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|starts_with|ends_with|not_contains|between|in|is_empty|is_not_empty|is_true|is_false)$/);
|
||||
if (operatorMatch) {
|
||||
filterKey = operatorMatch[1]; // e.g., "sales30d"
|
||||
operator = operatorMatch[2]; // e.g., "gt"
|
||||
}
|
||||
|
||||
// Get the database column for this filter key
|
||||
const dbColumn = getDbColumn(filterKey);
|
||||
const filterDbColumn = getDbColumn(filterKey);
|
||||
const valueType = getColumnType(filterKey);
|
||||
|
||||
if (!dbColumn) {
|
||||
|
||||
if (!filterDbColumn) {
|
||||
console.warn(`Invalid filter key ignored: ${key}`);
|
||||
continue; // Skip if the key doesn't map to a known column
|
||||
}
|
||||
|
||||
// --- Build WHERE clause fragment ---
|
||||
let needsParam = true; // Declared outside try so catch can access it
|
||||
try {
|
||||
let conditionFragment = '';
|
||||
let needsParam = true; // Most operators need a parameter
|
||||
|
||||
switch (operator.toLowerCase()) {
|
||||
case 'eq': operator = '='; break;
|
||||
@@ -417,48 +479,65 @@ router.get('/', async (req, res) => {
|
||||
case 'gte': operator = '>='; break;
|
||||
case 'lt': operator = '<'; break;
|
||||
case 'lte': operator = '<='; break;
|
||||
case 'like': operator = 'LIKE'; value = `%${value}%`; break; // Add wildcards for LIKE
|
||||
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; // Add wildcards for ILIKE
|
||||
case 'like': operator = 'ILIKE'; value = `%${value}%`; break;
|
||||
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break;
|
||||
case 'starts_with': operator = 'ILIKE'; value = `${value}%`; break;
|
||||
case 'ends_with': operator = 'ILIKE'; value = `%${value}`; break;
|
||||
case 'not_contains': operator = 'NOT ILIKE'; value = `%${value}%`; break;
|
||||
case 'is_empty':
|
||||
conditionFragment = `(${filterDbColumn} IS NULL OR ${filterDbColumn}::text = '')`;
|
||||
needsParam = false;
|
||||
break;
|
||||
case 'is_not_empty':
|
||||
conditionFragment = `(${filterDbColumn} IS NOT NULL AND ${filterDbColumn}::text <> '')`;
|
||||
needsParam = false;
|
||||
break;
|
||||
case 'is_true':
|
||||
conditionFragment = `${filterDbColumn} = true`;
|
||||
needsParam = false;
|
||||
break;
|
||||
case 'is_false':
|
||||
conditionFragment = `${filterDbColumn} = false`;
|
||||
needsParam = false;
|
||||
break;
|
||||
case 'between':
|
||||
const [val1, val2] = String(value).split(',');
|
||||
if (val1 !== undefined && val2 !== undefined) {
|
||||
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
|
||||
conditionFragment = `${filterDbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
|
||||
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
|
||||
needsParam = false; // Params added manually
|
||||
needsParam = false;
|
||||
} else {
|
||||
console.warn(`Invalid 'between' value for ${key}: ${value}`);
|
||||
continue; // Skip this filter
|
||||
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))); // Add all parsed values
|
||||
needsParam = false; // Params added manually
|
||||
conditionFragment = `${filterDbColumn} IN (${placeholders})`;
|
||||
params.push(...inValues.map(v => parseValue(v, valueType)));
|
||||
needsParam = false;
|
||||
} else {
|
||||
console.warn(`Invalid 'in' value for ${key}: ${value}`);
|
||||
continue; // Skip this filter
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
// Add other operators as needed (IS NULL, IS NOT NULL, etc.)
|
||||
case '=': // Keep default '='
|
||||
default: operator = '='; break; // Ensure default is handled
|
||||
case '=':
|
||||
default: operator = '='; break;
|
||||
}
|
||||
|
||||
if (needsParam) {
|
||||
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
|
||||
conditionFragment = `${filterDbColumn} ${operator} $${paramCounter++}`;
|
||||
params.push(parseValue(value, valueType));
|
||||
}
|
||||
|
||||
if (conditionFragment) {
|
||||
conditions.push(`(${conditionFragment})`); // Wrap condition in parentheses
|
||||
conditions.push(`(${conditionFragment})`);
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
|
||||
// Decrement counter if param wasn't actually used due to error
|
||||
if (needsParam) paramCounter--;
|
||||
}
|
||||
}
|
||||
@@ -466,13 +545,8 @@ router.get('/', async (req, res) => {
|
||||
// --- Construct and Execute Queries ---
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Debug log of conditions and parameters
|
||||
console.log('Constructed WHERE conditions:', conditions);
|
||||
console.log('Parameters:', params);
|
||||
|
||||
// Count Query
|
||||
const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`;
|
||||
console.log('Executing Count Query:', countSql, params);
|
||||
const countPromise = pool.query(countSql, params);
|
||||
|
||||
// Data Query (Select all columns from metrics table for now)
|
||||
@@ -484,16 +558,6 @@ router.get('/', async (req, res) => {
|
||||
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
||||
`;
|
||||
const dataParams = [...params, limit, offset];
|
||||
|
||||
// Log detailed query information for debugging
|
||||
console.log('Executing Data Query:');
|
||||
console.log(' - Sort Column:', dbColumn);
|
||||
console.log(' - Column Type:', columnType);
|
||||
console.log(' - Sort Direction:', sortDirection);
|
||||
console.log(' - Order By Clause:', orderByClause);
|
||||
console.log(' - Full SQL:', dataSql);
|
||||
console.log(' - Parameters:', dataParams);
|
||||
|
||||
const dataPromise = pool.query(dataSql, dataParams);
|
||||
|
||||
// Execute queries in parallel
|
||||
@@ -501,7 +565,6 @@ router.get('/', async (req, res) => {
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const metrics = dataResult.rows;
|
||||
console.log(`Total: ${total}, Fetched: ${metrics.length} for page ${page}`);
|
||||
|
||||
// --- Respond ---
|
||||
res.json({
|
||||
@@ -535,7 +598,6 @@ router.get('/:pid', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid Product ID.' });
|
||||
}
|
||||
|
||||
console.log(`GET /metrics/${pid}`);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM public.product_metrics WHERE pid = $1`,
|
||||
@@ -543,11 +605,8 @@ router.get('/:pid', async (req, res) => {
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(`Metrics not found for PID: ${pid}`);
|
||||
return res.status(404).json({ error: 'Metrics not found for this product.' });
|
||||
}
|
||||
|
||||
console.log(`Metrics found for PID: ${pid}`);
|
||||
// Data is pre-calculated, return the first (only) row
|
||||
res.json(rows[0]);
|
||||
|
||||
@@ -566,7 +625,7 @@ function parseValue(value, type) {
|
||||
if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently?
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'numeric':
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
|
||||
return num;
|
||||
|
||||
@@ -731,32 +731,33 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
LIMIT 10
|
||||
`, [id]);
|
||||
|
||||
// Get recent purchase orders with detailed status
|
||||
// Get recent purchase orders with received quantities from the receivings table
|
||||
const { rows: recentPurchases } = await pool.query(`
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM-DD') as date,
|
||||
TO_CHAR(expected_date, 'YYYY-MM-DD') as expected_date,
|
||||
TO_CHAR(received_date, 'YYYY-MM-DD') as received_date,
|
||||
po_id,
|
||||
ordered,
|
||||
received,
|
||||
status,
|
||||
receiving_status,
|
||||
cost_price,
|
||||
notes,
|
||||
CASE
|
||||
WHEN received_date IS NOT NULL THEN
|
||||
(received_date - date)
|
||||
WHEN expected_date < CURRENT_DATE AND status < $2 THEN
|
||||
(CURRENT_DATE - expected_date)
|
||||
ELSE NULL
|
||||
SELECT
|
||||
TO_CHAR(po.date, 'YYYY-MM-DD') as date,
|
||||
TO_CHAR(po.expected_date, 'YYYY-MM-DD') as expected_date,
|
||||
TO_CHAR(MAX(r.received_date), 'YYYY-MM-DD') as received_date,
|
||||
po.po_id,
|
||||
po.ordered,
|
||||
COALESCE(SUM(r.qty_each), 0)::integer as received,
|
||||
po.status,
|
||||
po.po_cost_price as cost_price,
|
||||
po.notes,
|
||||
CASE
|
||||
WHEN MAX(r.received_date) IS NOT NULL THEN
|
||||
EXTRACT(DAY FROM MAX(r.received_date) - po.date)::integer
|
||||
WHEN po.expected_date < CURRENT_DATE AND po.status NOT IN ('done', 'canceled') THEN
|
||||
(CURRENT_DATE - po.expected_date)
|
||||
ELSE NULL
|
||||
END as lead_time_days
|
||||
FROM purchase_orders
|
||||
WHERE pid = $1
|
||||
AND status != $3
|
||||
ORDER BY date DESC
|
||||
FROM purchase_orders po
|
||||
LEFT JOIN receivings r ON r.receiving_id = po.po_id AND r.pid = po.pid AND r.status != 'canceled'
|
||||
WHERE po.pid = $1
|
||||
AND po.status != 'canceled'
|
||||
GROUP BY po.id, po.po_id, po.date, po.expected_date, po.ordered, po.status, po.po_cost_price, po.notes
|
||||
ORDER BY po.date DESC
|
||||
LIMIT 10
|
||||
`, [id, PurchaseOrderStatus.ReceivingStarted, PurchaseOrderStatus.Canceled]);
|
||||
`, [id]);
|
||||
|
||||
res.json({
|
||||
monthly_sales: formattedMonthlySales,
|
||||
@@ -772,8 +773,7 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
...po,
|
||||
ordered: parseInt(po.ordered),
|
||||
received: parseInt(po.received),
|
||||
status: parseInt(po.status),
|
||||
receiving_status: parseInt(po.receiving_status),
|
||||
status: po.status, // Text-based status (e.g., 'done', 'ordered', 'receiving_started')
|
||||
cost_price: parseFloat(po.cost_price),
|
||||
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user