367 lines
16 KiB
JavaScript
367 lines
16 KiB
JavaScript
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),
|
|
timing: {
|
|
start_time: new Date(startTime).toISOString(),
|
|
end_time: new Date().toISOString(),
|
|
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
|
|
}
|
|
});
|
|
return processedCount;
|
|
}
|
|
|
|
// First ensure all products have a metrics record
|
|
await connection.query(`
|
|
INSERT IGNORE INTO product_metrics (pid, last_calculated_at)
|
|
SELECT pid, NOW()
|
|
FROM products
|
|
`);
|
|
|
|
// 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)
|
|
}
|
|
});
|
|
|
|
// Calculate base metrics
|
|
await connection.query(`
|
|
UPDATE product_metrics pm
|
|
JOIN (
|
|
SELECT
|
|
p.pid,
|
|
p.stock_quantity,
|
|
p.cost_price,
|
|
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, p.stock_quantity, p.cost_price
|
|
) 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.days_of_inventory = CASE
|
|
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
|
|
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days))
|
|
ELSE NULL
|
|
END,
|
|
pm.weeks_of_inventory = CASE
|
|
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
|
|
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days) / 7)
|
|
ELSE NULL
|
|
END,
|
|
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()
|
|
`);
|
|
|
|
// Calculate forecast accuracy and bias
|
|
await connection.query(`
|
|
WITH forecast_accuracy AS (
|
|
SELECT
|
|
sf.pid,
|
|
AVG(CASE
|
|
WHEN o.quantity > 0
|
|
THEN ABS(sf.forecast_units - o.quantity) / o.quantity * 100
|
|
ELSE 100
|
|
END) as avg_forecast_error,
|
|
AVG(CASE
|
|
WHEN o.quantity > 0
|
|
THEN (sf.forecast_units - 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 >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
|
GROUP BY sf.pid
|
|
)
|
|
UPDATE product_metrics pm
|
|
JOIN forecast_accuracy fa ON pm.pid = fa.pid
|
|
SET
|
|
pm.forecast_accuracy = GREATEST(0, 100 - LEAST(fa.avg_forecast_error, 100)),
|
|
pm.forecast_bias = GREATEST(-100, LEAST(fa.avg_forecast_bias, 100)),
|
|
pm.last_forecast_date = fa.last_forecast_date,
|
|
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),
|
|
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.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),
|
|
timing: {
|
|
start_time: new Date(startTime).toISOString(),
|
|
end_time: new Date().toISOString(),
|
|
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
|
|
}
|
|
});
|
|
}
|
|
|
|
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),
|
|
timing: {
|
|
start_time: new Date(startTime).toISOString(),
|
|
end_time: new Date().toISOString(),
|
|
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
|
|
}
|
|
});
|
|
|
|
// 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),
|
|
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,
|
|
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)
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|