const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate, logError } = require('./utils/progress'); const { getConnection } = require('./utils/db'); async function calculateSalesForecasts(startTime, totalProducts, processedCount = 0, isCancelled = false) { const connection = await getConnection(); let success = false; let myProcessedProducts = 0; // Track products processed *within this module* const BATCH_SIZE = 5000; try { // Get last calculation timestamp const [lastCalc] = await connection.query(` SELECT last_calculation_timestamp FROM calculate_status WHERE module_name = 'sales_forecasts' `); const lastCalculationTime = lastCalc[0]?.last_calculation_timestamp || '1970-01-01'; // Get total count of products needing updates const [productCount] = await connection.query(` SELECT COUNT(DISTINCT p.pid) as count FROM products p LEFT JOIN orders o ON p.pid = o.pid AND o.updated > ? WHERE p.visible = true AND ( p.updated > ? OR o.id IS NOT NULL ) `, [lastCalculationTime, lastCalculationTime]); const totalProductsToUpdate = productCount[0].count; if (totalProductsToUpdate === 0) { console.log('No products need forecast updates'); return { processedProducts: 0, processedOrders: 0, processedPurchaseOrders: 0, success: true }; } if (isCancelled) { outputProgress({ status: 'cancelled', operation: 'Sales forecast 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: myProcessedProducts, processedOrders: 0, processedPurchaseOrders: 0, success }; } outputProgress({ status: 'running', operation: 'Starting sales forecast calculation', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount, totalProducts), rate: calculateRate(startTime, processedCount), percentage: ((processedCount / totalProductsToUpdate) * 100).toFixed(1), timing: { start_time: new Date(startTime).toISOString(), end_time: new Date().toISOString(), elapsed_seconds: Math.round((Date.now() - startTime) / 1000) } }); // Process in batches let lastPid = ''; while (true) { if (isCancelled) break; const [batch] = await connection.query(` SELECT DISTINCT p.pid FROM products p FORCE INDEX (PRIMARY) LEFT JOIN orders o FORCE INDEX (idx_orders_metrics) ON p.pid = o.pid AND o.updated > ? WHERE p.visible = true AND p.pid > ? AND ( p.updated > ? OR o.id IS NOT NULL ) ORDER BY p.pid LIMIT ? `, [lastCalculationTime, lastPid, lastCalculationTime, BATCH_SIZE]); if (batch.length === 0) break; // Create temporary tables for better performance await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_historical_sales'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_sales_stats'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_recent_trend'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_confidence_calc'); // Create optimized temporary tables with indexes await connection.query(` CREATE TEMPORARY TABLE temp_historical_sales ( pid BIGINT NOT NULL, sale_date DATE NOT NULL, daily_quantity INT, daily_revenue DECIMAL(15,2), PRIMARY KEY (pid, sale_date), INDEX (sale_date) ) ENGINE=MEMORY `); await connection.query(` CREATE TEMPORARY TABLE temp_sales_stats ( pid BIGINT NOT NULL, avg_daily_units DECIMAL(10,2), avg_daily_revenue DECIMAL(15,2), std_daily_units DECIMAL(10,2), days_with_sales INT, first_sale DATE, last_sale DATE, PRIMARY KEY (pid), INDEX (days_with_sales), INDEX (last_sale) ) ENGINE=MEMORY `); await connection.query(` CREATE TEMPORARY TABLE temp_recent_trend ( pid BIGINT NOT NULL, recent_avg_units DECIMAL(10,2), recent_avg_revenue DECIMAL(15,2), PRIMARY KEY (pid) ) ENGINE=MEMORY `); await connection.query(` CREATE TEMPORARY TABLE temp_confidence_calc ( pid BIGINT NOT NULL, confidence_level TINYINT, PRIMARY KEY (pid) ) ENGINE=MEMORY `); // Populate historical sales with optimized index usage await connection.query(` INSERT INTO temp_historical_sales SELECT o.pid, DATE(o.date) as sale_date, SUM(o.quantity) as daily_quantity, SUM(o.quantity * o.price) as daily_revenue FROM orders o FORCE INDEX (idx_orders_metrics) WHERE o.canceled = false AND o.pid IN (?) AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 180 DAY) GROUP BY o.pid, DATE(o.date) `, [batch.map(row => row.pid)]); // Populate sales stats await connection.query(` INSERT INTO temp_sales_stats SELECT pid, AVG(daily_quantity) as avg_daily_units, AVG(daily_revenue) as avg_daily_revenue, STDDEV(daily_quantity) as std_daily_units, COUNT(*) as days_with_sales, MIN(sale_date) as first_sale, MAX(sale_date) as last_sale FROM temp_historical_sales GROUP BY pid `); // Populate recent trend await connection.query(` INSERT INTO temp_recent_trend SELECT h.pid, AVG(h.daily_quantity) as recent_avg_units, AVG(h.daily_revenue) as recent_avg_revenue FROM temp_historical_sales h WHERE h.sale_date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) GROUP BY h.pid `); // Calculate confidence levels await connection.query(` INSERT INTO temp_confidence_calc SELECT s.pid, LEAST(100, GREATEST(0, ROUND( (s.days_with_sales / 180.0 * 50) + -- Up to 50 points for history length (CASE WHEN s.std_daily_units = 0 OR s.avg_daily_units = 0 THEN 0 WHEN (s.std_daily_units / s.avg_daily_units) <= 0.5 THEN 30 WHEN (s.std_daily_units / s.avg_daily_units) <= 1.0 THEN 20 WHEN (s.std_daily_units / s.avg_daily_units) <= 2.0 THEN 10 ELSE 0 END) + -- Up to 30 points for consistency (CASE WHEN DATEDIFF(CURRENT_DATE, s.last_sale) <= 7 THEN 20 WHEN DATEDIFF(CURRENT_DATE, s.last_sale) <= 30 THEN 10 ELSE 0 END) -- Up to 20 points for recency ))) as confidence_level FROM temp_sales_stats s `); // Generate forecasts using temp tables await connection.query(` REPLACE INTO sales_forecasts (pid, forecast_date, forecast_units, forecast_revenue, confidence_level, last_calculated_at) SELECT s.pid, DATE_ADD(CURRENT_DATE, INTERVAL n.days DAY), GREATEST(0, ROUND( CASE WHEN s.days_with_sales >= n.days THEN COALESCE(t.recent_avg_units, s.avg_daily_units) ELSE s.avg_daily_units * (s.days_with_sales / n.days) END )), GREATEST(0, ROUND( CASE WHEN s.days_with_sales >= n.days THEN COALESCE(t.recent_avg_revenue, s.avg_daily_revenue) ELSE s.avg_daily_revenue * (s.days_with_sales / n.days) END, 2 )), c.confidence_level, NOW() FROM temp_sales_stats s CROSS JOIN ( SELECT 30 as days UNION SELECT 60 UNION SELECT 90 ) n LEFT JOIN temp_recent_trend t ON s.pid = t.pid LEFT JOIN temp_confidence_calc c ON s.pid = c.pid; `); // Clean up temp tables await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_historical_sales'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_sales_stats'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_recent_trend'); await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_confidence_calc'); lastPid = batch[batch.length - 1].pid; myProcessedProducts += batch.length; outputProgress({ status: 'running', operation: 'Processing sales forecast batch', current: processedCount + myProcessedProducts, total: totalProducts, elapsed: formatElapsedTime(startTime), remaining: estimateRemaining(startTime, processedCount + myProcessedProducts, totalProducts), rate: calculateRate(startTime, processedCount + myProcessedProducts), percentage: (((processedCount + myProcessedProducts) / 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 ('sales_forecasts', NOW()) ON DUPLICATE KEY UPDATE last_calculation_timestamp = NOW() `); return { processedProducts: myProcessedProducts, processedOrders: 0, processedPurchaseOrders: 0, success }; } catch (error) { success = false; logError(error, 'Error calculating sales forecasts'); throw error; } finally { if (connection) { connection.release(); } } } module.exports = calculateSalesForecasts;