244 lines
10 KiB
JavaScript
244 lines
10 KiB
JavaScript
const { outputProgress, 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) {
|
|
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;
|
|
|
|
// Calculate base product metrics
|
|
if (!SKIP_PRODUCT_BASE_METRICS) {
|
|
outputProgress({
|
|
status: 'running',
|
|
operation: 'Calculating base product metrics',
|
|
current: Math.floor(totalProducts * 0.2),
|
|
total: totalProducts,
|
|
elapsed: formatElapsedTime(startTime),
|
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.2), totalProducts),
|
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.2)),
|
|
percentage: '20'
|
|
});
|
|
|
|
// 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);
|
|
} else {
|
|
console.log('Skipping base product metrics calculation');
|
|
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: '40'
|
|
});
|
|
}
|
|
|
|
// Calculate product time aggregates
|
|
if (!SKIP_PRODUCT_TIME_AGGREGATES) {
|
|
outputProgress({
|
|
status: 'running',
|
|
operation: 'Calculating product time aggregates',
|
|
current: Math.floor(totalProducts * 0.4),
|
|
total: totalProducts,
|
|
elapsed: formatElapsedTime(startTime),
|
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.4), totalProducts),
|
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.4)),
|
|
percentage: '40'
|
|
});
|
|
|
|
// 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);
|
|
} else {
|
|
console.log('Skipping product time aggregates calculation');
|
|
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: '60'
|
|
});
|
|
}
|
|
|
|
return processedCount;
|
|
} 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;
|