737 lines
24 KiB
JavaScript
737 lines
24 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
|
const { getTimeRangeConditions, formatBusinessDate, getBusinessDayBounds } = require('../utils/timeUtils');
|
|
|
|
// Image URL generation utility
|
|
const getImageUrls = (pid, iid = 1) => {
|
|
const imageUrlBase = 'https://sbing.com/i/products/0000/';
|
|
const paddedPid = pid.toString().padStart(6, '0');
|
|
const prefix = paddedPid.slice(0, 3);
|
|
const basePath = `${imageUrlBase}${prefix}/${pid}`;
|
|
return {
|
|
image: `${basePath}-t-${iid}.jpg`,
|
|
image_175: `${basePath}-175x175-${iid}.jpg`,
|
|
image_full: `${basePath}-o-${iid}.jpg`,
|
|
ImgThumb: `${basePath}-175x175-${iid}.jpg` // For ProductGrid component
|
|
};
|
|
};
|
|
|
|
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
|
router.get('/stats', async (req, res) => {
|
|
const startTime = Date.now();
|
|
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`);
|
|
|
|
// Set a timeout for the entire operation
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000);
|
|
});
|
|
|
|
try {
|
|
const mainOperation = async () => {
|
|
const { timeRange, startDate, endDate } = req.query;
|
|
console.log(`[STATS] Getting DB connection...`);
|
|
const { connection, release } = await getDbConnection();
|
|
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
|
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Main order stats query
|
|
const mainStatsQuery = `
|
|
SELECT
|
|
COUNT(*) as orderCount,
|
|
SUM(summary_total) as revenue,
|
|
SUM(stats_prod_pieces) as itemCount,
|
|
AVG(summary_total) as averageOrderValue,
|
|
AVG(stats_prod_pieces) as averageItemsPerOrder,
|
|
SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount,
|
|
SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount,
|
|
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount,
|
|
SUM(CASE WHEN order_status IN (100, 92) THEN 1 ELSE 0 END) as shippedCount,
|
|
SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount,
|
|
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
`;
|
|
|
|
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
|
const stats = mainStats[0];
|
|
|
|
// Refunds query
|
|
const refundsQuery = `
|
|
SELECT
|
|
COUNT(*) as refundCount,
|
|
ABS(SUM(payment_amount)) as refundTotal
|
|
FROM order_payment op
|
|
JOIN _order o ON op.order_id = o.order_id
|
|
WHERE payment_amount < 0 AND o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
`;
|
|
|
|
const [refundStats] = await connection.execute(refundsQuery, params);
|
|
|
|
// Best revenue day query
|
|
const bestDayQuery = `
|
|
SELECT
|
|
DATE(date_placed) as date,
|
|
SUM(summary_total) as revenue,
|
|
COUNT(*) as orders
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
GROUP BY DATE(date_placed)
|
|
ORDER BY revenue DESC
|
|
LIMIT 1
|
|
`;
|
|
|
|
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
|
|
|
// Peak hour query (for single day periods)
|
|
let peakHour = null;
|
|
if (['today', 'yesterday'].includes(timeRange)) {
|
|
const peakHourQuery = `
|
|
SELECT
|
|
HOUR(date_placed) as hour,
|
|
COUNT(*) as count
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
GROUP BY HOUR(date_placed)
|
|
ORDER BY count DESC
|
|
LIMIT 1
|
|
`;
|
|
|
|
const [peakHourResult] = await connection.execute(peakHourQuery, params);
|
|
if (peakHourResult.length > 0) {
|
|
const hour = peakHourResult[0].hour;
|
|
const date = new Date();
|
|
date.setHours(hour, 0, 0);
|
|
peakHour = {
|
|
hour,
|
|
count: peakHourResult[0].count,
|
|
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
|
|
};
|
|
}
|
|
}
|
|
|
|
// Brands and categories query - simplified for now since we don't have the category tables
|
|
// We'll use a simple approach without company table for now
|
|
const brandsQuery = `
|
|
SELECT
|
|
'Various Brands' as brandName,
|
|
COUNT(DISTINCT oi.order_id) as orderCount,
|
|
SUM(oi.qty_ordered) as itemCount,
|
|
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
|
FROM order_items oi
|
|
JOIN _order o ON oi.order_id = o.order_id
|
|
JOIN products p ON oi.prod_pid = p.pid
|
|
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
HAVING revenue > 0
|
|
`;
|
|
|
|
const [brandsResult] = await connection.execute(brandsQuery, params);
|
|
|
|
// For categories, we'll use a simplified approach
|
|
const categoriesQuery = `
|
|
SELECT
|
|
'General' as categoryName,
|
|
COUNT(DISTINCT oi.order_id) as orderCount,
|
|
SUM(oi.qty_ordered) as itemCount,
|
|
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
|
FROM order_items oi
|
|
JOIN _order o ON oi.order_id = o.order_id
|
|
JOIN products p ON oi.prod_pid = p.pid
|
|
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
HAVING revenue > 0
|
|
`;
|
|
|
|
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
|
|
|
// Shipping locations query
|
|
const shippingQuery = `
|
|
SELECT
|
|
ship_country,
|
|
ship_state,
|
|
ship_method_selected,
|
|
COUNT(*) as count
|
|
FROM _order
|
|
WHERE order_status IN (100, 92) AND ${whereClause}
|
|
GROUP BY ship_country, ship_state, ship_method_selected
|
|
`;
|
|
|
|
const [shippingResult] = await connection.execute(shippingQuery, params);
|
|
|
|
// Process shipping data
|
|
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
|
|
|
// Order value range query
|
|
const orderRangeQuery = `
|
|
SELECT
|
|
MIN(summary_total) as smallest,
|
|
MAX(summary_total) as largest
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
`;
|
|
|
|
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
|
|
|
// Calculate period progress for incomplete periods
|
|
let periodProgress = 100;
|
|
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
|
periodProgress = calculatePeriodProgress(timeRange);
|
|
}
|
|
|
|
// Previous period comparison data
|
|
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate);
|
|
|
|
const response = {
|
|
timeRange: dateRange,
|
|
stats: {
|
|
revenue: parseFloat(stats.revenue || 0),
|
|
orderCount: parseInt(stats.orderCount || 0),
|
|
itemCount: parseInt(stats.itemCount || 0),
|
|
averageOrderValue: parseFloat(stats.averageOrderValue || 0),
|
|
averageItemsPerOrder: parseFloat(stats.averageItemsPerOrder || 0),
|
|
|
|
// Order types
|
|
orderTypes: {
|
|
preOrders: {
|
|
count: parseInt(stats.preOrderCount || 0),
|
|
percentage: stats.orderCount > 0 ? (stats.preOrderCount / stats.orderCount) * 100 : 0
|
|
},
|
|
localPickup: {
|
|
count: parseInt(stats.localPickupCount || 0),
|
|
percentage: stats.orderCount > 0 ? (stats.localPickupCount / stats.orderCount) * 100 : 0
|
|
},
|
|
heldItems: {
|
|
count: parseInt(stats.onHoldCount || 0),
|
|
percentage: stats.orderCount > 0 ? (stats.onHoldCount / stats.orderCount) * 100 : 0
|
|
}
|
|
},
|
|
|
|
// Shipping
|
|
shipping: {
|
|
shippedCount: parseInt(stats.shippedCount || 0),
|
|
locations: shippingStats.locations,
|
|
methodStats: shippingStats.methods
|
|
},
|
|
|
|
// Brands and categories
|
|
brands: {
|
|
total: brandsResult.length,
|
|
list: brandsResult.slice(0, 50).map(brand => ({
|
|
name: brand.brandName,
|
|
count: parseInt(brand.itemCount),
|
|
revenue: parseFloat(brand.revenue)
|
|
}))
|
|
},
|
|
|
|
categories: {
|
|
total: categoriesResult.length,
|
|
list: categoriesResult.slice(0, 50).map(category => ({
|
|
name: category.categoryName,
|
|
count: parseInt(category.itemCount),
|
|
revenue: parseFloat(category.revenue)
|
|
}))
|
|
},
|
|
|
|
// Refunds and cancellations
|
|
refunds: {
|
|
total: parseFloat(refundStats[0]?.refundTotal || 0),
|
|
count: parseInt(refundStats[0]?.refundCount || 0)
|
|
},
|
|
|
|
canceledOrders: {
|
|
total: parseFloat(stats.cancelledTotal || 0),
|
|
count: parseInt(stats.cancelledCount || 0)
|
|
},
|
|
|
|
// Best day
|
|
bestRevenueDay: bestDayResult.length > 0 ? {
|
|
amount: parseFloat(bestDayResult[0].revenue),
|
|
displayDate: bestDayResult[0].date,
|
|
orders: parseInt(bestDayResult[0].orders)
|
|
} : null,
|
|
|
|
// Peak hour (for single days)
|
|
peakOrderHour: peakHour,
|
|
|
|
// Order value range
|
|
orderValueRange: orderRangeResult.length > 0 ? {
|
|
smallest: parseFloat(orderRangeResult[0].smallest || 0),
|
|
largest: parseFloat(orderRangeResult[0].largest || 0)
|
|
} : { smallest: 0, largest: 0 },
|
|
|
|
// Period progress and projections
|
|
periodProgress,
|
|
projectedRevenue: periodProgress < 100 ? (stats.revenue / (periodProgress / 100)) : stats.revenue,
|
|
|
|
// Previous period comparison
|
|
prevPeriodRevenue: prevPeriodData.revenue,
|
|
prevPeriodOrders: prevPeriodData.orderCount,
|
|
prevPeriodAOV: prevPeriodData.averageOrderValue
|
|
}
|
|
};
|
|
|
|
return { response, release };
|
|
};
|
|
|
|
// Race between the main operation and timeout
|
|
let result;
|
|
try {
|
|
result = await Promise.race([mainOperation(), timeoutPromise]);
|
|
} catch (error) {
|
|
// If it's a timeout, we don't have a release function to call
|
|
if (error.message.includes('timeout')) {
|
|
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
|
throw error;
|
|
}
|
|
// For other errors, re-throw
|
|
throw error;
|
|
}
|
|
|
|
const { response, release } = result;
|
|
|
|
// Release connection back to pool
|
|
if (release) release();
|
|
|
|
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
|
|
res.json(response);
|
|
|
|
} catch (error) {
|
|
console.error('Error in /stats:', error);
|
|
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Daily details endpoint - replaces /api/klaviyo/events/stats/details
|
|
router.get('/stats/details', async (req, res) => {
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate, metric, daily } = req.query;
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Daily breakdown query
|
|
const dailyQuery = `
|
|
SELECT
|
|
DATE(date_placed) as date,
|
|
COUNT(*) as orders,
|
|
SUM(summary_total) as revenue,
|
|
AVG(summary_total) as averageOrderValue,
|
|
SUM(stats_prod_pieces) as itemCount
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
GROUP BY DATE(date_placed)
|
|
ORDER BY DATE(date_placed)
|
|
`;
|
|
|
|
const [dailyResults] = await connection.execute(dailyQuery, params);
|
|
|
|
// Get all previous year data in a single query
|
|
const prevYearQuery = `
|
|
SELECT
|
|
DATE(date_placed) as date,
|
|
COUNT(*) as prevOrders,
|
|
SUM(summary_total) as prevRevenue,
|
|
AVG(summary_total) as prevAvgOrderValue
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause.replace(/date_placed/g, 'DATE_SUB(date_placed, INTERVAL 1 YEAR)')}
|
|
GROUP BY DATE(date_placed)
|
|
`;
|
|
|
|
const [prevYearResults] = await connection.execute(prevYearQuery, params);
|
|
|
|
// Create a map for quick lookup of previous year data
|
|
const prevYearMap = new Map();
|
|
prevYearResults.forEach(prev => {
|
|
const currentYearDate = new Date(prev.date);
|
|
currentYearDate.setFullYear(currentYearDate.getFullYear() + 1);
|
|
const key = currentYearDate.toISOString().split('T')[0];
|
|
prevYearMap.set(key, prev);
|
|
});
|
|
|
|
// Combine current and previous year data
|
|
const statsWithComparison = dailyResults.map(day => {
|
|
const dayKey = new Date(day.date).toISOString().split('T')[0];
|
|
const prev = prevYearMap.get(dayKey) || { prevOrders: 0, prevRevenue: 0, prevAvgOrderValue: 0 };
|
|
|
|
return {
|
|
timestamp: day.date,
|
|
date: day.date,
|
|
orders: parseInt(day.orders),
|
|
revenue: parseFloat(day.revenue),
|
|
averageOrderValue: parseFloat(day.averageOrderValue || 0),
|
|
itemCount: parseInt(day.itemCount),
|
|
prevOrders: parseInt(prev.prevOrders || 0),
|
|
prevRevenue: parseFloat(prev.prevRevenue || 0),
|
|
prevAvgOrderValue: parseFloat(prev.prevAvgOrderValue || 0)
|
|
};
|
|
});
|
|
|
|
res.json({ stats: statsWithComparison });
|
|
|
|
} catch (error) {
|
|
console.error('Error in /stats/details:', error);
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
// Release connection back to pool
|
|
if (release) release();
|
|
}
|
|
});
|
|
|
|
// Products endpoint - replaces /api/klaviyo/events/products
|
|
router.get('/products', async (req, res) => {
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate } = req.query;
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
const productsQuery = `
|
|
SELECT
|
|
p.pid,
|
|
p.description as name,
|
|
SUM(oi.qty_ordered) as totalQuantity,
|
|
SUM(oi.qty_ordered * oi.prod_price) as totalRevenue,
|
|
COUNT(DISTINCT oi.order_id) as orderCount,
|
|
(SELECT pi.iid FROM product_images pi WHERE pi.pid = p.pid AND pi.order = 255 LIMIT 1) as primary_iid
|
|
FROM order_items oi
|
|
JOIN _order o ON oi.order_id = o.order_id
|
|
JOIN products p ON oi.prod_pid = p.pid
|
|
WHERE o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
GROUP BY p.pid, p.description
|
|
ORDER BY totalRevenue DESC
|
|
LIMIT 500
|
|
`;
|
|
|
|
const [productsResult] = await connection.execute(productsQuery, params);
|
|
|
|
// Add image URLs to each product
|
|
const productsWithImages = productsResult.map(product => {
|
|
const imageUrls = getImageUrls(product.pid, product.primary_iid || 1);
|
|
return {
|
|
id: product.pid,
|
|
name: product.name,
|
|
totalQuantity: parseInt(product.totalQuantity),
|
|
totalRevenue: parseFloat(product.totalRevenue),
|
|
orderCount: parseInt(product.orderCount),
|
|
...imageUrls
|
|
};
|
|
});
|
|
|
|
res.json({
|
|
stats: {
|
|
products: {
|
|
total: productsWithImages.length,
|
|
list: productsWithImages
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error in /products:', error);
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
// Release connection back to pool
|
|
if (release) release();
|
|
}
|
|
});
|
|
|
|
// Projection endpoint - replaces /api/klaviyo/events/projection
|
|
router.get('/projection', async (req, res) => {
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate } = req.query;
|
|
|
|
// Only provide projections for incomplete periods
|
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
|
return res.json({ projectedRevenue: 0, confidence: 0 });
|
|
}
|
|
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
// Get current period data
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
const currentQuery = `
|
|
SELECT
|
|
SUM(summary_total) as currentRevenue,
|
|
COUNT(*) as currentOrders
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${whereClause}
|
|
`;
|
|
|
|
const [currentResult] = await connection.execute(currentQuery, params);
|
|
const current = currentResult[0];
|
|
|
|
// Get historical data for the same period type
|
|
const historicalQuery = await getHistoricalProjectionData(connection, timeRange);
|
|
|
|
// Calculate projection based on current progress and historical patterns
|
|
const periodProgress = calculatePeriodProgress(timeRange);
|
|
const projection = calculateSmartProjection(
|
|
parseFloat(current.currentRevenue || 0),
|
|
parseInt(current.currentOrders || 0),
|
|
periodProgress,
|
|
historicalQuery
|
|
);
|
|
|
|
res.json(projection);
|
|
|
|
} catch (error) {
|
|
console.error('Error in /projection:', error);
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
// Release connection back to pool
|
|
if (release) release();
|
|
}
|
|
});
|
|
|
|
// Debug endpoint to check connection pool status
|
|
router.get('/debug/pool', (req, res) => {
|
|
res.json(getPoolStatus());
|
|
});
|
|
|
|
// Health check endpoint
|
|
router.get('/health', async (req, res) => {
|
|
try {
|
|
const { connection, release } = await getDbConnection();
|
|
|
|
// Simple query to test connection
|
|
const [result] = await connection.execute('SELECT 1 as test');
|
|
release();
|
|
|
|
res.json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
pool: getPoolStatus(),
|
|
dbTest: result[0]
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'unhealthy',
|
|
error: error.message,
|
|
timestamp: new Date().toISOString(),
|
|
pool: getPoolStatus()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Helper functions
|
|
function processShippingData(shippingResult, totalShipped) {
|
|
const countries = {};
|
|
const states = {};
|
|
const methods = {};
|
|
|
|
shippingResult.forEach(row => {
|
|
// Countries
|
|
if (row.ship_country) {
|
|
countries[row.ship_country] = (countries[row.ship_country] || 0) + row.count;
|
|
}
|
|
|
|
// States
|
|
if (row.ship_state) {
|
|
states[row.ship_state] = (states[row.ship_state] || 0) + row.count;
|
|
}
|
|
|
|
// Methods
|
|
if (row.ship_method_selected) {
|
|
methods[row.ship_method_selected] = (methods[row.ship_method_selected] || 0) + row.count;
|
|
}
|
|
});
|
|
|
|
return {
|
|
locations: {
|
|
total: totalShipped,
|
|
byCountry: Object.entries(countries)
|
|
.map(([country, count]) => ({
|
|
country,
|
|
count,
|
|
percentage: (count / totalShipped) * 100
|
|
}))
|
|
.sort((a, b) => b.count - a.count),
|
|
byState: Object.entries(states)
|
|
.map(([state, count]) => ({
|
|
state,
|
|
count,
|
|
percentage: (count / totalShipped) * 100
|
|
}))
|
|
.sort((a, b) => b.count - a.count)
|
|
},
|
|
methods: Object.entries(methods)
|
|
.map(([name, value]) => ({ name, value }))
|
|
.sort((a, b) => b.value - a.value)
|
|
};
|
|
}
|
|
|
|
function calculatePeriodProgress(timeRange) {
|
|
const now = new Date();
|
|
const easternTime = new Date(now.getTime() - (5 * 60 * 60 * 1000)); // UTC-5
|
|
|
|
switch (timeRange) {
|
|
case 'today': {
|
|
const { start } = getBusinessDayBounds('today');
|
|
const businessStart = new Date(start);
|
|
const businessEnd = new Date(businessStart);
|
|
businessEnd.setDate(businessEnd.getDate() + 1);
|
|
businessEnd.setHours(0, 59, 59, 999); // 12:59 AM next day
|
|
|
|
const elapsed = easternTime.getTime() - businessStart.getTime();
|
|
const total = businessEnd.getTime() - businessStart.getTime();
|
|
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
|
}
|
|
case 'thisWeek': {
|
|
const startOfWeek = new Date(easternTime);
|
|
startOfWeek.setDate(easternTime.getDate() - easternTime.getDay()); // Sunday
|
|
startOfWeek.setHours(1, 0, 0, 0); // 1 AM business day start
|
|
|
|
const endOfWeek = new Date(startOfWeek);
|
|
endOfWeek.setDate(endOfWeek.getDate() + 7);
|
|
|
|
const elapsed = easternTime.getTime() - startOfWeek.getTime();
|
|
const total = endOfWeek.getTime() - startOfWeek.getTime();
|
|
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
|
}
|
|
case 'thisMonth': {
|
|
const startOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth(), 1, 1, 0, 0, 0);
|
|
const endOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth() + 1, 1, 0, 59, 59, 999);
|
|
|
|
const elapsed = easternTime.getTime() - startOfMonth.getTime();
|
|
const total = endOfMonth.getTime() - startOfMonth.getTime();
|
|
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
|
}
|
|
default:
|
|
return 100;
|
|
}
|
|
}
|
|
|
|
async function getPreviousPeriodData(connection, timeRange, startDate, endDate) {
|
|
// Calculate previous period dates
|
|
let prevWhereClause, prevParams;
|
|
|
|
if (timeRange && timeRange !== 'custom') {
|
|
const prevTimeRange = getPreviousTimeRange(timeRange);
|
|
const result = getTimeRangeConditions(prevTimeRange);
|
|
prevWhereClause = result.whereClause;
|
|
prevParams = result.params;
|
|
} else {
|
|
// Custom date range - go back by the same duration
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
const duration = end.getTime() - start.getTime();
|
|
|
|
const prevEnd = new Date(start.getTime() - 1);
|
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
|
|
|
prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
|
|
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
|
}
|
|
|
|
const prevQuery = `
|
|
SELECT
|
|
COUNT(*) as orderCount,
|
|
SUM(summary_total) as revenue,
|
|
AVG(summary_total) as averageOrderValue
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${prevWhereClause}
|
|
`;
|
|
|
|
const [prevResult] = await connection.execute(prevQuery, prevParams);
|
|
const prev = prevResult[0] || { orderCount: 0, revenue: 0, averageOrderValue: 0 };
|
|
|
|
return {
|
|
orderCount: parseInt(prev.orderCount || 0),
|
|
revenue: parseFloat(prev.revenue || 0),
|
|
averageOrderValue: parseFloat(prev.averageOrderValue || 0)
|
|
};
|
|
}
|
|
|
|
function getPreviousTimeRange(timeRange) {
|
|
const map = {
|
|
today: 'yesterday',
|
|
thisWeek: 'lastWeek',
|
|
thisMonth: 'lastMonth',
|
|
last7days: 'previous7days',
|
|
last30days: 'previous30days',
|
|
last90days: 'previous90days',
|
|
yesterday: 'twoDaysAgo'
|
|
};
|
|
return map[timeRange] || timeRange;
|
|
}
|
|
|
|
async function getHistoricalProjectionData(connection, timeRange) {
|
|
// Get historical data for projection calculations
|
|
// This is a simplified version - you could make this more sophisticated
|
|
const historicalQuery = `
|
|
SELECT
|
|
SUM(summary_total) as revenue,
|
|
COUNT(*) as orders
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
|
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
|
`;
|
|
|
|
const [result] = await connection.execute(historicalQuery);
|
|
return result;
|
|
}
|
|
|
|
function calculateSmartProjection(currentRevenue, currentOrders, periodProgress, historicalData) {
|
|
if (periodProgress >= 100) {
|
|
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
|
|
}
|
|
|
|
// Simple linear projection with confidence based on how much of the period has elapsed
|
|
const projectedRevenue = currentRevenue / (periodProgress / 100);
|
|
const projectedOrders = Math.round(currentOrders / (periodProgress / 100));
|
|
|
|
// Confidence increases with more data (higher period progress)
|
|
const confidence = Math.min(0.95, Math.max(0.1, periodProgress / 100));
|
|
|
|
return {
|
|
projectedRevenue,
|
|
projectedOrders,
|
|
confidence
|
|
};
|
|
}
|
|
|
|
// Health check endpoint
|
|
router.get('/health', async (req, res) => {
|
|
try {
|
|
const poolStatus = getPoolStatus();
|
|
|
|
// Test database connectivity
|
|
const { connection, release } = await getDbConnection();
|
|
await connection.execute('SELECT 1 as test');
|
|
release();
|
|
|
|
res.json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
pool: poolStatus,
|
|
database: 'connected'
|
|
});
|
|
} catch (error) {
|
|
console.error('Health check failed:', error);
|
|
res.status(500).json({
|
|
status: 'unhealthy',
|
|
timestamp: new Date().toISOString(),
|
|
error: error.message,
|
|
pool: getPoolStatus()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Debug endpoint for pool status
|
|
router.get('/debug/pool', (req, res) => {
|
|
res.json({
|
|
timestamp: new Date().toISOString(),
|
|
pool: getPoolStatus()
|
|
});
|
|
});
|
|
|
|
module.exports = router;
|