Fixed calculations for frontend (likely still wrong but they display) + related regressions to calculate script
This commit is contained in:
@@ -13,6 +13,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
const connection = await getConnection();
|
const connection = await getConnection();
|
||||||
let success = false;
|
let success = false;
|
||||||
let processedOrders = 0;
|
let processedOrders = 0;
|
||||||
|
const BATCH_SIZE = 5000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip flags are inherited from the parent scope
|
// Skip flags are inherited from the parent scope
|
||||||
@@ -44,7 +45,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
return {
|
return {
|
||||||
processedProducts: processedCount,
|
processedProducts: processedCount,
|
||||||
processedOrders,
|
processedOrders,
|
||||||
processedPurchaseOrders: 0, // This module doesn't process POs
|
processedPurchaseOrders: 0,
|
||||||
success
|
success
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,15 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
FROM products
|
FROM products
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
`);
|
||||||
|
const defaultThresholds = thresholds[0];
|
||||||
|
|
||||||
// Calculate base product metrics
|
// Calculate base product metrics
|
||||||
if (!SKIP_PRODUCT_BASE_METRICS) {
|
if (!SKIP_PRODUCT_BASE_METRICS) {
|
||||||
outputProgress({
|
outputProgress({
|
||||||
@@ -82,63 +92,157 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
`);
|
`);
|
||||||
processedOrders = orderCount[0].count;
|
processedOrders = orderCount[0].count;
|
||||||
|
|
||||||
// Calculate base metrics
|
// Clear temporary tables
|
||||||
|
await connection.query('TRUNCATE TABLE temp_sales_metrics');
|
||||||
|
await connection.query('TRUNCATE TABLE temp_purchase_metrics');
|
||||||
|
|
||||||
|
// Populate temp_sales_metrics with base stats and sales averages
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
UPDATE product_metrics pm
|
INSERT INTO temp_sales_metrics
|
||||||
JOIN (
|
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
p.stock_quantity,
|
COALESCE(SUM(o.quantity) / NULLIF(COUNT(DISTINCT DATE(o.date)), 0), 0) as daily_sales_avg,
|
||||||
p.cost_price,
|
COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 7), 0), 0) as weekly_sales_avg,
|
||||||
p.cost_price * p.stock_quantity as inventory_value,
|
COALESCE(SUM(o.quantity) / NULLIF(CEIL(COUNT(DISTINCT DATE(o.date)) / 30), 0), 0) as monthly_sales_avg,
|
||||||
SUM(o.quantity) as total_quantity,
|
COALESCE(SUM(o.quantity * o.price), 0) as total_revenue,
|
||||||
COUNT(DISTINCT o.order_number) as number_of_orders,
|
CASE
|
||||||
SUM(o.quantity * o.price) as total_revenue,
|
WHEN SUM(o.quantity * o.price) > 0
|
||||||
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
|
THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100
|
||||||
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
|
ELSE 0
|
||||||
END,
|
END as avg_margin_percent,
|
||||||
pm.first_sale_date = stats.first_sale_date,
|
MIN(o.date) as first_sale_date,
|
||||||
pm.last_sale_date = stats.last_sale_date,
|
MAX(o.date) as last_sale_date
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
AND o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
|
||||||
|
GROUP BY p.pid
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Populate temp_purchase_metrics
|
||||||
|
await connection.query(`
|
||||||
|
INSERT INTO temp_purchase_metrics
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
AVG(DATEDIFF(po.received_date, po.date)) 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
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN purchase_orders po ON p.pid = po.pid
|
||||||
|
AND po.received_date IS NOT NULL
|
||||||
|
AND po.date >= DATE_SUB(CURDATE(), INTERVAL 365 DAY)
|
||||||
|
GROUP BY p.pid
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Process updates in batches
|
||||||
|
let lastPid = 0;
|
||||||
|
while (true) {
|
||||||
|
if (isCancelled) break;
|
||||||
|
|
||||||
|
const [batch] = await connection.query(
|
||||||
|
'SELECT pid FROM products WHERE pid > ? ORDER BY pid LIMIT ?',
|
||||||
|
[lastPid, BATCH_SIZE]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
JOIN products p ON pm.pid = p.pid
|
||||||
|
LEFT JOIN temp_sales_metrics sm ON pm.pid = sm.pid
|
||||||
|
LEFT JOIN temp_purchase_metrics lm ON pm.pid = lm.pid
|
||||||
|
SET
|
||||||
|
pm.inventory_value = p.stock_quantity * p.cost_price,
|
||||||
|
pm.daily_sales_avg = COALESCE(sm.daily_sales_avg, 0),
|
||||||
|
pm.weekly_sales_avg = COALESCE(sm.weekly_sales_avg, 0),
|
||||||
|
pm.monthly_sales_avg = COALESCE(sm.monthly_sales_avg, 0),
|
||||||
|
pm.total_revenue = COALESCE(sm.total_revenue, 0),
|
||||||
|
pm.avg_margin_percent = COALESCE(sm.avg_margin_percent, 0),
|
||||||
|
pm.first_sale_date = sm.first_sale_date,
|
||||||
|
pm.last_sale_date = sm.last_sale_date,
|
||||||
|
pm.avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30),
|
||||||
pm.days_of_inventory = CASE
|
pm.days_of_inventory = CASE
|
||||||
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0
|
||||||
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days))
|
THEN FLOOR(p.stock_quantity / sm.daily_sales_avg)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END,
|
END,
|
||||||
pm.weeks_of_inventory = CASE
|
pm.weeks_of_inventory = CASE
|
||||||
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
|
WHEN COALESCE(sm.weekly_sales_avg, 0) > 0
|
||||||
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days) / 7)
|
THEN FLOOR(p.stock_quantity / sm.weekly_sales_avg)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END,
|
END,
|
||||||
pm.gmroi = CASE
|
pm.stock_status = CASE
|
||||||
WHEN COALESCE(stats.inventory_value, 0) > 0
|
WHEN p.stock_quantity <= 0 THEN 'Out of Stock'
|
||||||
THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value
|
WHEN COALESCE(sm.daily_sales_avg, 0) = 0 AND p.stock_quantity <= ? THEN 'Low Stock'
|
||||||
|
WHEN COALESCE(sm.daily_sales_avg, 0) = 0 THEN 'In Stock'
|
||||||
|
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ? THEN 'Critical'
|
||||||
|
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ? THEN 'Reorder'
|
||||||
|
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ? THEN 'Overstocked'
|
||||||
|
ELSE 'Healthy'
|
||||||
|
END,
|
||||||
|
pm.reorder_qty = CASE
|
||||||
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN
|
||||||
|
GREATEST(
|
||||||
|
CEIL(sm.daily_sales_avg * COALESCE(lm.avg_lead_time_days, 30) * 1.96),
|
||||||
|
?
|
||||||
|
)
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
pm.overstocked_amt = CASE
|
||||||
|
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ?
|
||||||
|
THEN GREATEST(0, p.stock_quantity - CEIL(sm.daily_sales_avg * ?))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END,
|
END,
|
||||||
pm.last_calculated_at = NOW()
|
pm.last_calculated_at = NOW()
|
||||||
`);
|
WHERE p.pid IN (?)
|
||||||
|
`, [
|
||||||
|
defaultThresholds.low_stock_threshold,
|
||||||
|
defaultThresholds.critical_days,
|
||||||
|
defaultThresholds.reorder_days,
|
||||||
|
defaultThresholds.overstock_days,
|
||||||
|
defaultThresholds.low_stock_threshold,
|
||||||
|
defaultThresholds.low_stock_threshold,
|
||||||
|
defaultThresholds.overstock_days,
|
||||||
|
defaultThresholds.overstock_days,
|
||||||
|
batch.map(row => row.pid)
|
||||||
|
]);
|
||||||
|
|
||||||
|
lastPid = batch[batch.length - 1].pid;
|
||||||
|
processedCount += batch.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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate forecast accuracy and bias in batches
|
||||||
|
lastPid = 0;
|
||||||
|
while (true) {
|
||||||
|
if (isCancelled) break;
|
||||||
|
|
||||||
|
const [batch] = await connection.query(
|
||||||
|
'SELECT pid FROM products WHERE pid > ? ORDER BY pid LIMIT ?',
|
||||||
|
[lastPid, BATCH_SIZE]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
// Calculate forecast accuracy and bias
|
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
WITH forecast_accuracy AS (
|
UPDATE product_metrics pm
|
||||||
|
JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
sf.pid,
|
sf.pid,
|
||||||
AVG(CASE
|
AVG(CASE
|
||||||
@@ -157,58 +261,20 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
AND DATE(o.date) = sf.forecast_date
|
AND DATE(o.date) = sf.forecast_date
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND sf.forecast_date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
AND sf.forecast_date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
||||||
|
AND sf.pid IN (?)
|
||||||
GROUP BY sf.pid
|
GROUP BY sf.pid
|
||||||
)
|
) fa ON pm.pid = fa.pid
|
||||||
UPDATE product_metrics pm
|
|
||||||
JOIN forecast_accuracy fa ON pm.pid = fa.pid
|
|
||||||
SET
|
SET
|
||||||
pm.forecast_accuracy = GREATEST(0, 100 - LEAST(fa.avg_forecast_error, 100)),
|
pm.forecast_accuracy = GREATEST(0, 100 - LEAST(fa.avg_forecast_error, 100)),
|
||||||
pm.forecast_bias = GREATEST(-100, LEAST(fa.avg_forecast_bias, 100)),
|
pm.forecast_bias = GREATEST(-100, LEAST(fa.avg_forecast_bias, 100)),
|
||||||
pm.last_forecast_date = fa.last_forecast_date,
|
pm.last_forecast_date = fa.last_forecast_date,
|
||||||
pm.last_calculated_at = NOW()
|
pm.last_calculated_at = NOW()
|
||||||
`);
|
WHERE pm.pid IN (?)
|
||||||
|
`, [batch.map(row => row.pid), batch.map(row => row.pid)]);
|
||||||
|
|
||||||
processedCount = Math.floor(totalProducts * 0.4);
|
lastPid = batch[batch.length - 1].pid;
|
||||||
outputProgress({
|
|
||||||
status: 'running',
|
|
||||||
operation: 'Base product metrics calculated',
|
|
||||||
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.4);
|
|
||||||
outputProgress({
|
|
||||||
status: 'running',
|
|
||||||
operation: 'Skipping base product metrics 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)
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelled) return {
|
|
||||||
processedProducts: processedCount || 0,
|
|
||||||
processedOrders: processedOrders || 0,
|
|
||||||
processedPurchaseOrders: 0, // This module doesn't process POs
|
|
||||||
success
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate product time aggregates
|
// Calculate product time aggregates
|
||||||
if (!SKIP_PRODUCT_TIME_AGGREGATES) {
|
if (!SKIP_PRODUCT_TIME_AGGREGATES) {
|
||||||
|
|||||||
Reference in New Issue
Block a user