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) { const connection = await getConnection(); try { // Skip flags are inherited from the parent scope const SKIP_PRODUCT_BASE_METRICS = 0; const SKIP_PRODUCT_TIME_AGGREGATES = 0; 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) }); return processedCount; } // 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) }); // Calculate base metrics await connection.query(` UPDATE product_metrics pm JOIN ( SELECT p.pid, p.cost_price * p.stock_quantity as inventory_value, SUM(o.quantity) as total_quantity, COUNT(DISTINCT o.order_number) as number_of_orders, SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * p.cost_price) as cost_of_goods_sold, AVG(o.price) as avg_price, STDDEV(o.price) as price_std, MIN(o.date) as first_sale_date, MAX(o.date) as last_sale_date, COUNT(DISTINCT DATE(o.date)) as active_days FROM products p LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false GROUP BY p.pid ) stats ON pm.pid = stats.pid SET pm.inventory_value = COALESCE(stats.inventory_value, 0), pm.avg_quantity_per_order = COALESCE(stats.total_quantity / NULLIF(stats.number_of_orders, 0), 0), pm.number_of_orders = COALESCE(stats.number_of_orders, 0), pm.total_revenue = COALESCE(stats.total_revenue, 0), pm.cost_of_goods_sold = COALESCE(stats.cost_of_goods_sold, 0), pm.gross_profit = COALESCE(stats.total_revenue - stats.cost_of_goods_sold, 0), pm.avg_margin_percent = CASE WHEN COALESCE(stats.total_revenue, 0) > 0 THEN ((stats.total_revenue - stats.cost_of_goods_sold) / stats.total_revenue) * 100 ELSE 0 END, pm.first_sale_date = stats.first_sale_date, pm.last_sale_date = stats.last_sale_date, pm.gmroi = CASE WHEN COALESCE(stats.inventory_value, 0) > 0 THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value ELSE 0 END, pm.last_calculated_at = NOW() `); processedCount = Math.floor(totalProducts * 0.4); outputProgress({ status: 'running', operation: 'Base product metrics calculated', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1) }); } else { processedCount = Math.floor(totalProducts * 0.4); outputProgress({ status: 'running', operation: 'Skipping 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) }); } if (isCancelled) return processedCount; // Calculate product time aggregates if (!SKIP_PRODUCT_TIME_AGGREGATES) { outputProgress({ status: 'running', operation: 'Starting product time aggregates calculation', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1) }); // Calculate time-based aggregates await connection.query(` INSERT INTO product_time_aggregates ( pid, year, month, total_quantity_sold, total_revenue, total_cost, order_count, avg_price, profit_margin, inventory_value, gmroi ) SELECT p.pid, YEAR(o.date) as year, MONTH(o.date) as month, SUM(o.quantity) as total_quantity_sold, SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * p.cost_price) as total_cost, COUNT(DISTINCT o.order_number) as order_count, AVG(o.price) as avg_price, 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 profit_margin, p.cost_price * p.stock_quantity as inventory_value, CASE WHEN p.cost_price * p.stock_quantity > 0 THEN (SUM(o.quantity * (o.price - p.cost_price))) / (p.cost_price * p.stock_quantity) ELSE 0 END as gmroi FROM products p LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) GROUP BY p.pid, YEAR(o.date), MONTH(o.date) ON DUPLICATE KEY UPDATE total_quantity_sold = VALUES(total_quantity_sold), total_revenue = VALUES(total_revenue), total_cost = VALUES(total_cost), order_count = VALUES(order_count), avg_price = VALUES(avg_price), profit_margin = VALUES(profit_margin), inventory_value = VALUES(inventory_value), gmroi = VALUES(gmroi) `); processedCount = Math.floor(totalProducts * 0.6); outputProgress({ status: 'running', operation: 'Product time aggregates calculated', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1) }); } else { processedCount = Math.floor(totalProducts * 0.6); outputProgress({ status: 'running', operation: 'Skipping product time aggregates calculation', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProducts) * 100).toFixed(1) }); } return processedCount; } catch (error) { logError(error, 'Error calculating product metrics'); throw error; } finally { if (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'; } function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) { // Calculate safety stock based on service level and lead time const z_score = 1.96; // 95% service level const lead_time = avg_lead_time || config.target_days; const safety_stock = Math.ceil(daily_sales_avg * Math.sqrt(lead_time) * z_score); // Calculate reorder point const lead_time_demand = daily_sales_avg * lead_time; const reorder_point = Math.ceil(lead_time_demand + safety_stock); // Calculate reorder quantity using EOQ formula if we have the necessary data let reorder_qty = 0; if (daily_sales_avg > 0) { const annual_demand = daily_sales_avg * 365; const order_cost = 25; // Fixed cost per order const holding_cost_percent = 0.25; // 25% annual holding cost reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost_percent)); } else { // If no sales data, use a basic calculation reorder_qty = Math.max(safety_stock, config.low_stock_threshold); } // Calculate overstocked amount const overstocked_amt = stock_status === 'Overstocked' ? stock - Math.ceil(daily_sales_avg * config.overstock_days) : 0; return { safety_stock, reorder_point, reorder_qty, overstocked_amt }; } module.exports = calculateProductMetrics;