const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); const { getConnection } = require('./utils/db'); // Helper function to handle NaN and undefined values function sanitizeValue(value) { if (value === undefined || value === null || Number.isNaN(value)) { return null; } return value; } async function calculateProductMetrics(startTime, totalProducts, processedCount = 0, isCancelled = false) { let connection; let success = false; let processedOrders = 0; const BATCH_SIZE = 5000; try { connection = await getConnection(); // Skip flags are inherited from the parent scope const SKIP_PRODUCT_BASE_METRICS = 0; const SKIP_PRODUCT_TIME_AGGREGATES = 0; // Get total product count if not provided if (!totalProducts) { const productCount = await connection.query('SELECT COUNT(*) as count FROM products'); totalProducts = parseInt(productCount.rows[0].count); } if (isCancelled) { outputProgress({ status: 'cancelled', operation: 'Product metrics calculation cancelled', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: null, rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); return { processedProducts: processedCount, processedOrders, processedPurchaseOrders: 0, success }; } // First ensure all products have a metrics record await connection.query(` INSERT INTO product_metrics (pid, last_calculated_at) SELECT pid, NOW() FROM products ON CONFLICT (pid) DO NOTHING `); // Get threshold settings once const thresholds = await connection.query(` SELECT critical_days, reorder_days, overstock_days, low_stock_threshold FROM stock_thresholds WHERE category_id IS NULL AND vendor IS NULL LIMIT 1 `); // Check if threshold data was returned if (!thresholds.rows || thresholds.rows.length === 0) { console.warn('No default thresholds found in the database. Using explicit type casting in the query.'); } const defaultThresholds = thresholds.rows[0]; // Get financial calculation configuration parameters const financialConfig = await connection.query(` SELECT order_cost, holding_rate, service_level_z_score, min_reorder_qty, default_reorder_qty, default_safety_stock FROM financial_calc_config WHERE id = 1 LIMIT 1 `); const finConfig = financialConfig.rows[0] || { order_cost: 25.00, holding_rate: 0.25, service_level_z_score: 1.96, min_reorder_qty: 1, default_reorder_qty: 5, default_safety_stock: 5 }; // Calculate base product metrics if (!SKIP_PRODUCT_BASE_METRICS) { outputProgress({ status: 'running', operation: 'Starting base product metrics calculation', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); // Get order count that will be processed const orderCount = await connection.query(` SELECT COUNT(*) as count FROM orders o WHERE o.canceled = false `); processedOrders = parseInt(orderCount.rows[0].count); // Clear temporary tables await connection.query('DROP TABLE IF EXISTS temp_sales_metrics'); await connection.query('DROP TABLE IF EXISTS temp_purchase_metrics'); // Create temp_sales_metrics await connection.query(` CREATE TEMPORARY TABLE temp_sales_metrics ( pid BIGINT NOT NULL, daily_sales_avg DECIMAL(10,3), weekly_sales_avg DECIMAL(10,3), monthly_sales_avg DECIMAL(10,3), total_revenue DECIMAL(10,3), avg_margin_percent DECIMAL(10,3), first_sale_date DATE, last_sale_date DATE, stddev_daily_sales DECIMAL(10,3), PRIMARY KEY (pid) ) `); // Create temp_purchase_metrics await connection.query(` CREATE TEMPORARY TABLE temp_purchase_metrics ( pid BIGINT NOT NULL, avg_lead_time_days DECIMAL(10,2), last_purchase_date DATE, first_received_date DATE, last_received_date DATE, stddev_lead_time_days DECIMAL(10,2), PRIMARY KEY (pid) ) `); // Populate temp_sales_metrics with base stats and sales averages await connection.query(` INSERT INTO temp_sales_metrics SELECT p.pid, COALESCE(SUM(o.quantity) / NULLIF(COUNT(DISTINCT DATE(o.date)), 0), 0) as daily_sales_avg, COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 7), 0), 0) as weekly_sales_avg, COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 30), 0), 0) as monthly_sales_avg, COALESCE(SUM(o.quantity * o.price), 0) as total_revenue, CASE WHEN SUM(o.quantity * o.price) > 0 THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100 ELSE 0 END as avg_margin_percent, MIN(o.date) as first_sale_date, MAX(o.date) as last_sale_date, COALESCE(STDDEV_SAMP(daily_qty.quantity), 0) as stddev_daily_sales FROM products p LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false AND o.date >= CURRENT_DATE - INTERVAL '90 days' LEFT JOIN ( SELECT pid, DATE(date) as sale_date, SUM(quantity) as quantity FROM orders WHERE canceled = false AND date >= CURRENT_DATE - INTERVAL '90 days' GROUP BY pid, DATE(date) ) daily_qty ON p.pid = daily_qty.pid GROUP BY p.pid `); // Populate temp_purchase_metrics with timeout protection await Promise.race([ connection.query(` INSERT INTO temp_purchase_metrics SELECT p.pid, AVG( CASE WHEN po.received_date IS NOT NULL AND po.date IS NOT NULL THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 ELSE NULL END ) as avg_lead_time_days, MAX(po.date) as last_purchase_date, MIN(po.received_date) as first_received_date, MAX(po.received_date) as last_received_date, STDDEV_SAMP( CASE WHEN po.received_date IS NOT NULL AND po.date IS NOT NULL THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 ELSE NULL END ) as stddev_lead_time_days FROM products p LEFT JOIN purchase_orders po ON p.pid = po.pid AND po.received_date IS NOT NULL AND po.date IS NOT NULL AND po.date >= CURRENT_DATE - INTERVAL '365 days' GROUP BY p.pid `), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: temp_purchase_metrics query took too long')), 60000) ) ]).catch(async (err) => { logError(err, 'Error populating temp_purchase_metrics, continuing with empty table'); // Create an empty fallback to continue processing await connection.query(` INSERT INTO temp_purchase_metrics SELECT p.pid, 30.0 as avg_lead_time_days, NULL as last_purchase_date, NULL as first_received_date, NULL as last_received_date, 0.0 as stddev_lead_time_days FROM products p LEFT JOIN temp_purchase_metrics tpm ON p.pid = tpm.pid WHERE tpm.pid IS NULL `); }); // Process updates in batches let lastPid = 0; let batchCount = 0; const MAX_BATCHES = 1000; // Safety limit for number of batches to prevent infinite loops while (batchCount < MAX_BATCHES) { if (isCancelled) break; batchCount++; const batch = await connection.query( 'SELECT pid FROM products WHERE pid > $1 ORDER BY pid LIMIT $2', [lastPid, BATCH_SIZE] ); if (batch.rows.length === 0) break; // Process the entire batch in a single efficient query const lowStockThreshold = parseInt(defaultThresholds?.low_stock_threshold) || 5; const criticalDays = parseInt(defaultThresholds?.critical_days) || 7; const reorderDays = parseInt(defaultThresholds?.reorder_days) || 14; const overstockDays = parseInt(defaultThresholds?.overstock_days) || 90; const serviceLevel = parseFloat(finConfig?.service_level_z_score) || 1.96; const defaultSafetyStock = parseInt(finConfig?.default_safety_stock) || 5; const defaultReorderQty = parseInt(finConfig?.default_reorder_qty) || 5; const orderCost = parseFloat(finConfig?.order_cost) || 25.00; const holdingRate = parseFloat(finConfig?.holding_rate) || 0.25; const minReorderQty = parseInt(finConfig?.min_reorder_qty) || 1; await connection.query(` UPDATE product_metrics pm SET inventory_value = p.stock_quantity * NULLIF(p.cost_price, 0), daily_sales_avg = COALESCE(sm.daily_sales_avg, 0), weekly_sales_avg = COALESCE(sm.weekly_sales_avg, 0), monthly_sales_avg = COALESCE(sm.monthly_sales_avg, 0), total_revenue = COALESCE(sm.total_revenue, 0), avg_margin_percent = COALESCE(sm.avg_margin_percent, 0), first_sale_date = sm.first_sale_date, last_sale_date = sm.last_sale_date, avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30.0), days_of_inventory = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN FLOOR(p.stock_quantity / NULLIF(sm.daily_sales_avg, 0)) ELSE NULL END, weeks_of_inventory = CASE WHEN COALESCE(sm.weekly_sales_avg, 0) > 0 THEN FLOOR(p.stock_quantity / NULLIF(sm.weekly_sales_avg, 0)) ELSE NULL END, stock_status = CASE WHEN p.stock_quantity <= 0 THEN 'Out of Stock' WHEN COALESCE(sm.daily_sales_avg, 0) = 0 AND p.stock_quantity <= ${lowStockThreshold} THEN 'Low Stock' WHEN COALESCE(sm.daily_sales_avg, 0) = 0 THEN 'In Stock' WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${criticalDays} THEN 'Critical' WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${reorderDays} THEN 'Reorder' WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} THEN 'Overstocked' ELSE 'Healthy' END, safety_stock = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN CEIL( ${serviceLevel} * SQRT( GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) ) ) ELSE ${defaultSafetyStock} END, reorder_point = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN CEIL(sm.daily_sales_avg * GREATEST(0, COALESCE(lm.avg_lead_time_days, 30.0))) + (CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN CEIL( ${serviceLevel} * SQRT( GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) ) ) ELSE ${defaultSafetyStock} END) ELSE ${lowStockThreshold} END, reorder_qty = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND NULLIF(p.cost_price, 0) IS NOT NULL AND NULLIF(p.cost_price, 0) > 0 THEN GREATEST( CEIL(SQRT( (2 * (sm.daily_sales_avg * 365) * ${orderCost}) / NULLIF(p.cost_price * ${holdingRate}, 0) )), ${minReorderQty} ) ELSE ${defaultReorderQty} END, overstocked_amt = CASE WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} THEN GREATEST(0, p.stock_quantity - CEIL(sm.daily_sales_avg * ${overstockDays})) ELSE 0 END, last_calculated_at = NOW() FROM products p LEFT JOIN temp_sales_metrics sm ON p.pid = sm.pid LEFT JOIN temp_purchase_metrics lm ON p.pid = lm.pid WHERE p.pid = ANY($1::BIGINT[]) AND pm.pid = p.pid `, [batch.rows.map(row => row.pid)]); lastPid = batch.rows[batch.rows.length - 1].pid; processedCount += batch.rows.length; outputProgress({ status: 'running', operation: 'Processing base metrics batch', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); } // Add safety check if the loop processed MAX_BATCHES if (batchCount >= MAX_BATCHES) { logError(new Error(`Reached maximum batch count (${MAX_BATCHES}). Process may have entered an infinite loop.`), 'Batch processing safety limit reached'); } } // Calculate forecast accuracy and bias in batches let forecastPid = 0; while (true) { if (isCancelled) break; const forecastBatch = await connection.query( 'SELECT pid FROM products WHERE pid > $1 ORDER BY pid LIMIT $2', [forecastPid, BATCH_SIZE] ); if (forecastBatch.rows.length === 0) break; const forecastPidArray = forecastBatch.rows.map(row => row.pid); // Use array_to_string to convert the array to a string of comma-separated values await connection.query(` WITH forecast_metrics AS ( SELECT sf.pid, AVG(CASE WHEN o.quantity > 0 THEN ABS(sf.forecast_quantity - o.quantity) / o.quantity * 100 ELSE 100 END) as avg_forecast_error, AVG(CASE WHEN o.quantity > 0 THEN (sf.forecast_quantity - o.quantity) / o.quantity * 100 ELSE 0 END) as avg_forecast_bias, MAX(sf.forecast_date) as last_forecast_date FROM sales_forecasts sf JOIN orders o ON sf.pid = o.pid AND DATE(o.date) = sf.forecast_date WHERE o.canceled = false AND sf.forecast_date >= CURRENT_DATE - INTERVAL '90 days' AND sf.pid = ANY('{${forecastPidArray.join(',')}}'::BIGINT[]) GROUP BY sf.pid ) UPDATE product_metrics pm SET forecast_accuracy = GREATEST(0, 100 - LEAST(fm.avg_forecast_error, 100)), forecast_bias = GREATEST(-100, LEAST(fm.avg_forecast_bias, 100)), last_forecast_date = fm.last_forecast_date, last_calculated_at = NOW() FROM forecast_metrics fm WHERE pm.pid = fm.pid `); forecastPid = forecastBatch.rows[forecastBatch.rows.length - 1].pid; } // Calculate product time aggregates if (!SKIP_PRODUCT_TIME_AGGREGATES) { outputProgress({ status: 'running', operation: 'Starting product time aggregates calculation', current: processedCount || 0, total: totalProducts || 0, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), rate: calculateRate(startTime, processedCount || 0), percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); // Note: The time-aggregates calculation has been moved to time-aggregates.js // This module will not duplicate that functionality processedCount = Math.floor(totalProducts * 0.6); outputProgress({ status: 'running', operation: 'Product time aggregates calculation delegated to time-aggregates module', current: processedCount || 0, total: totalProducts || 0, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), rate: calculateRate(startTime, processedCount || 0), percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); } else { processedCount = Math.floor(totalProducts * 0.6); outputProgress({ status: 'running', operation: 'Skipping product time aggregates calculation', current: processedCount || 0, total: totalProducts || 0, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount || 0, totalProducts || 0), rate: calculateRate(startTime, processedCount || 0), percentage: (((processedCount || 0) / (totalProducts || 1)) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); } // Calculate ABC classification outputProgress({ status: 'running', operation: 'Starting ABC classification', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); if (isCancelled) return { processedProducts: processedCount, processedOrders, processedPurchaseOrders: 0, // This module doesn't process POs success }; const abcConfig = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1'); const abcThresholds = abcConfig.rows[0] || { a_threshold: 20, b_threshold: 50 }; // Extract values and ensure they are valid numbers const aThreshold = parseFloat(abcThresholds.a_threshold) || 20; const bThreshold = parseFloat(abcThresholds.b_threshold) || 50; // First, create and populate the rankings table with an index await connection.query('DROP TABLE IF EXISTS temp_revenue_ranks'); await connection.query(` CREATE TEMPORARY TABLE temp_revenue_ranks ( pid BIGINT NOT NULL, total_revenue DECIMAL(10,3), rank_num INT, dense_rank_num INT, percentile DECIMAL(5,2), total_count INT, PRIMARY KEY (pid) ) `); await connection.query('CREATE INDEX ON temp_revenue_ranks (rank_num)'); await connection.query('CREATE INDEX ON temp_revenue_ranks (dense_rank_num)'); await connection.query('CREATE INDEX ON temp_revenue_ranks (percentile)'); // Calculate rankings with proper tie handling await connection.query(` INSERT INTO temp_revenue_ranks WITH revenue_data AS ( SELECT pid, total_revenue, COUNT(*) OVER () as total_count, PERCENT_RANK() OVER (ORDER BY total_revenue DESC) * 100 as percentile, RANK() OVER (ORDER BY total_revenue DESC) as rank_num, DENSE_RANK() OVER (ORDER BY total_revenue DESC) as dense_rank_num FROM product_metrics WHERE total_revenue > 0 ) SELECT pid, total_revenue, rank_num, dense_rank_num, percentile, total_count FROM revenue_data `); // Get total count for percentage calculation const rankingCount = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks'); const totalCount = parseInt(rankingCount.rows[0].total_count) || 1; // Process updates in batches let abcProcessedCount = 0; const batchSize = 5000; const maxPid = await connection.query('SELECT MAX(pid) as max_pid FROM products'); const maxProductId = parseInt(maxPid.rows[0].max_pid); while (abcProcessedCount < maxProductId) { if (isCancelled) return { processedProducts: processedCount, processedOrders, processedPurchaseOrders: 0, success }; // Get a batch of PIDs that need updating const pids = await connection.query(` SELECT pm.pid FROM product_metrics pm LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid WHERE pm.pid > $1 AND (pm.abc_class IS NULL OR pm.abc_class != CASE WHEN tr.pid IS NULL THEN 'C' WHEN tr.percentile <= ${aThreshold} THEN 'A' WHEN tr.percentile <= ${bThreshold} THEN 'B' ELSE 'C' END) ORDER BY pm.pid LIMIT $2 `, [abcProcessedCount, batchSize]); if (pids.rows.length === 0) break; const pidValues = pids.rows.map(row => row.pid); await connection.query(` UPDATE product_metrics pm SET abc_class = CASE WHEN tr.pid IS NULL THEN 'C' WHEN tr.percentile <= ${aThreshold} THEN 'A' WHEN tr.percentile <= ${bThreshold} THEN 'B' ELSE 'C' END, last_calculated_at = NOW() FROM (SELECT pid, percentile FROM temp_revenue_ranks) tr WHERE pm.pid = tr.pid AND pm.pid = ANY($1::BIGINT[]) OR (pm.pid = ANY($1::BIGINT[]) AND tr.pid IS NULL) `, [pidValues]); // Now update turnover rate with proper handling of zero inventory periods await connection.query(` UPDATE product_metrics pm SET turnover_rate = CASE WHEN sales.avg_nonzero_stock > 0 AND sales.active_days > 0 THEN LEAST( (sales.total_sold / sales.avg_nonzero_stock) * (365.0 / sales.active_days), 999.99 ) ELSE 0 END, last_calculated_at = NOW() FROM ( SELECT o.pid, SUM(o.quantity) as total_sold, COUNT(DISTINCT DATE(o.date)) as active_days, AVG(CASE WHEN p.stock_quantity > 0 THEN p.stock_quantity ELSE NULL END) as avg_nonzero_stock FROM orders o JOIN products p ON o.pid = p.pid WHERE o.canceled = false AND o.date >= CURRENT_DATE - INTERVAL '90 days' AND o.pid = ANY($1::BIGINT[]) GROUP BY o.pid ) sales WHERE pm.pid = sales.pid `, [pidValues]); abcProcessedCount = pids.rows[pids.rows.length - 1].pid; // Calculate progress proportionally to total products processedCount = Math.floor(totalProducts * (0.60 + (abcProcessedCount / maxProductId) * 0.2)); outputProgress({ status: 'running', operation: 'ABC classification progress', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); } // If we get here, everything completed successfully success = true; // Update calculate_status await connection.query(` INSERT INTO calculate_status (module_name, last_calculation_timestamp) VALUES ('product_metrics', NOW()) ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = NOW() `); return { processedProducts: processedCount || 0, processedOrders: processedOrders || 0, processedPurchaseOrders: 0, // This module doesn't process POs success }; } catch (error) { success = false; logError(error, 'Error calculating product metrics'); throw error; } finally { // Always clean up temporary tables, even if an error occurred if (connection) { try { await connection.query('DROP TABLE IF EXISTS temp_sales_metrics'); await connection.query('DROP TABLE IF EXISTS temp_purchase_metrics'); } catch (err) { console.error('Error cleaning up temporary tables:', err); } // Make sure to release the connection connection.release(); } } } function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) { if (stock <= 0) { return 'Out of Stock'; } // Use the most appropriate sales average based on data quality let sales_avg = daily_sales_avg; if (sales_avg === 0) { sales_avg = weekly_sales_avg / 7; } if (sales_avg === 0) { sales_avg = monthly_sales_avg / 30; } if (sales_avg === 0) { return stock <= config.low_stock_threshold ? 'Low Stock' : 'In Stock'; } const days_of_stock = stock / sales_avg; if (days_of_stock <= config.critical_days) { return 'Critical'; } else if (days_of_stock <= config.reorder_days) { return 'Reorder'; } else if (days_of_stock > config.overstock_days) { return 'Overstocked'; } return 'Healthy'; } // Note: calculateReorderQuantities function has been removed as its logic has been incorporated // in the main SQL query with configurable parameters module.exports = calculateProductMetrics;