1798 lines
62 KiB
JavaScript
1798 lines
62 KiB
JavaScript
const express = require('express');
|
|
const { DateTime } = require('luxon');
|
|
|
|
const router = express.Router();
|
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
|
const {
|
|
getTimeRangeConditions,
|
|
formatBusinessDate,
|
|
getBusinessDayBounds,
|
|
_internal: timeHelpers
|
|
} = require('../utils/timeUtils');
|
|
|
|
const TIMEZONE = 'America/New_York';
|
|
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
|
|
|
|
// Cherry Box order types to exclude when excludeCherryBox=true is passed
|
|
// 3 = cherrybox_subscription, 4 = cherrybox_sending, 5 = cherrybox_subscription_renew, 7 = cherrybox_refund
|
|
const EXCLUDED_ORDER_TYPES = [3, 4, 5, 7];
|
|
const getCherryBoxClause = (exclude) => exclude ? `order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
|
const getCherryBoxClauseAliased = (alias, exclude) => exclude ? `${alias}.order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
|
const parseBoolParam = (value) => value === 'true' || value === '1';
|
|
|
|
// 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, excludeCherryBox } = req.query;
|
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
|
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 (optionally excludes Cherry Box orders)
|
|
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
|
|
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
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
|
`;
|
|
|
|
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
|
const stats = mainStats[0];
|
|
|
|
// Cancelled orders query - uses date_cancelled instead of date_placed
|
|
// Shows orders cancelled during the selected period, regardless of when they were placed
|
|
const cancelledQuery = `
|
|
SELECT
|
|
COUNT(*) as cancelledCount,
|
|
SUM(summary_total) as cancelledTotal
|
|
FROM _order
|
|
WHERE order_status = 15
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
|
`;
|
|
|
|
const [cancelledResult] = await connection.execute(cancelledQuery, params);
|
|
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
|
|
|
|
// Refunds query (optionally excludes Cherry Box orders)
|
|
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 ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
`;
|
|
|
|
const [refundStats] = await connection.execute(refundsQuery, params);
|
|
|
|
// Shipped orders query - uses date_shipped instead of date_placed
|
|
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
|
|
const shippedQuery = `
|
|
SELECT COUNT(*) as shippedCount
|
|
FROM _order
|
|
WHERE order_status IN (92, 95, 100)
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
|
`;
|
|
|
|
const [shippedResult] = await connection.execute(shippedQuery, params);
|
|
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
|
|
|
|
// Best revenue day query (optionally excludes Cherry Box orders)
|
|
const bestDayQuery = `
|
|
SELECT
|
|
DATE(date_placed) as date,
|
|
SUM(summary_total) as revenue,
|
|
COUNT(*) as orders
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
|
GROUP BY DATE(date_placed)
|
|
ORDER BY revenue DESC
|
|
LIMIT 1
|
|
`;
|
|
|
|
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
|
|
|
// Peak hour query - uses selected time range for the card value
|
|
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 ${getCherryBoxClause(excludeCB)} 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: parseInt(peakHourResult[0].count),
|
|
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
|
|
};
|
|
}
|
|
}
|
|
|
|
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
|
|
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
|
|
let hourlyOrders = null;
|
|
if (['today', 'yesterday'].includes(timeRange)) {
|
|
// Get hourly counts AND current hour from MySQL to avoid timezone mismatch
|
|
const hourlyQuery = `
|
|
SELECT
|
|
HOUR(date_placed) as hour,
|
|
COUNT(*) as count,
|
|
HOUR(NOW()) as currentHour
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
|
GROUP BY HOUR(date_placed)
|
|
`;
|
|
|
|
const [hourlyResult] = await connection.execute(hourlyQuery);
|
|
|
|
// Get current hour from MySQL (same timezone as the WHERE clause)
|
|
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
|
|
|
|
// Build map of hour -> count
|
|
const hourCounts = {};
|
|
hourlyResult.forEach(row => {
|
|
hourCounts[parseInt(row.hour)] = parseInt(row.count);
|
|
});
|
|
|
|
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
|
|
hourlyOrders = [];
|
|
for (let i = 0; i < 24; i++) {
|
|
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
|
|
hourlyOrders.push({
|
|
hour,
|
|
count: hourCounts[hour] || 0
|
|
});
|
|
}
|
|
}
|
|
|
|
// Brands query - products.company links to product_categories.cat_id for brand name
|
|
// Only include products that have a brand assigned (INNER JOIN)
|
|
const brandsQuery = `
|
|
SELECT
|
|
pc.cat_id as catId,
|
|
pc.name 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
|
|
JOIN product_categories pc ON p.company = pc.cat_id
|
|
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
GROUP BY pc.cat_id, pc.name
|
|
HAVING revenue > 0
|
|
ORDER BY revenue DESC
|
|
LIMIT 100
|
|
`;
|
|
|
|
const [brandsResult] = await connection.execute(brandsQuery, params);
|
|
|
|
// Categories query - uses product_category_index to get category assignments
|
|
// Only include categories with valid types (no NULL/uncategorized)
|
|
const categoriesQuery = `
|
|
SELECT
|
|
pc.cat_id as catId,
|
|
pc.name 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
|
|
JOIN product_category_index pci ON p.pid = pci.pid
|
|
JOIN product_categories pc ON pci.cat_id = pc.cat_id
|
|
WHERE o.order_status > 15
|
|
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
|
AND pc.type IN (10, 20, 11, 21, 12, 13)
|
|
GROUP BY pc.cat_id, pc.name
|
|
HAVING revenue > 0
|
|
ORDER BY revenue DESC
|
|
LIMIT 100
|
|
`;
|
|
|
|
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
|
|
|
// Shipping locations query - uses date_shipped to match shippedCount
|
|
const shippingQuery = `
|
|
SELECT
|
|
ship_country,
|
|
ship_state,
|
|
ship_method_selected,
|
|
COUNT(*) as count
|
|
FROM _order
|
|
WHERE order_status IN (92, 95, 100)
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
|
GROUP BY ship_country, ship_state, ship_method_selected
|
|
`;
|
|
|
|
const [shippingResult] = await connection.execute(shippingQuery, params);
|
|
|
|
// Process shipping data
|
|
const shippingStats = processShippingData(shippingResult, shippedCount);
|
|
|
|
// Order value range query (optionally excludes Cherry Box orders)
|
|
// Excludes $0 orders from min calculation
|
|
const orderRangeQuery = `
|
|
SELECT
|
|
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
|
|
MAX(summary_total) as largest
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} 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, excludeCB);
|
|
|
|
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(shippedCount || 0),
|
|
locations: shippingStats.locations,
|
|
methodStats: shippingStats.methods
|
|
},
|
|
|
|
// Brands and categories
|
|
brands: {
|
|
total: brandsResult.length,
|
|
list: brandsResult.slice(0, 50).map(brand => ({
|
|
id: brand.catId,
|
|
name: brand.brandName,
|
|
count: parseInt(brand.itemCount),
|
|
revenue: parseFloat(brand.revenue)
|
|
}))
|
|
},
|
|
|
|
categories: {
|
|
total: categoriesResult.length,
|
|
list: categoriesResult.slice(0, 50).map(category => ({
|
|
id: category.catId,
|
|
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(cancelledStats.cancelledTotal || 0),
|
|
count: parseInt(cancelledStats.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,
|
|
hourlyOrders: hourlyOrders, // Array of 24 hourly order counts for the detail chart
|
|
|
|
// 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, excludeCherryBox, orderType, eventType } = req.query;
|
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Handle special event types (refunds, cancellations)
|
|
if (eventType === 'PAYMENT_REFUNDED') {
|
|
// Refunds query - from order_payment table
|
|
const refundsQuery = `
|
|
SELECT
|
|
DATE(op.payment_date) as date,
|
|
COUNT(*) as count,
|
|
ABS(SUM(op.payment_amount)) as total
|
|
FROM order_payment op
|
|
JOIN _order o ON op.order_id = o.order_id
|
|
WHERE op.payment_amount < 0
|
|
AND o.order_status > 15
|
|
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'op.payment_date')}
|
|
GROUP BY DATE(op.payment_date)
|
|
ORDER BY DATE(op.payment_date)
|
|
`;
|
|
|
|
const [refundResults] = await connection.execute(refundsQuery, params);
|
|
|
|
// Format matches what frontend expects: day.refunds.total, day.refunds.count
|
|
const stats = refundResults.map(day => ({
|
|
timestamp: day.date,
|
|
date: day.date,
|
|
refunds: {
|
|
total: parseFloat(day.total || 0),
|
|
count: parseInt(day.count || 0),
|
|
reasons: {}
|
|
}
|
|
}));
|
|
|
|
if (release) release();
|
|
return res.json({ stats });
|
|
}
|
|
|
|
if (eventType === 'CANCELED_ORDER') {
|
|
// Cancellations query - uses date_cancelled to show when orders were actually cancelled
|
|
const cancelQuery = `
|
|
SELECT
|
|
DATE(date_cancelled) as date,
|
|
COUNT(*) as count,
|
|
SUM(summary_total) as total
|
|
FROM _order
|
|
WHERE order_status = 15
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
|
GROUP BY DATE(date_cancelled)
|
|
ORDER BY DATE(date_cancelled)
|
|
`;
|
|
|
|
const [cancelResults] = await connection.execute(cancelQuery, params);
|
|
|
|
// Format matches what frontend expects: day.canceledOrders.total, day.canceledOrders.count
|
|
const stats = cancelResults.map(day => ({
|
|
timestamp: day.date,
|
|
date: day.date,
|
|
canceledOrders: {
|
|
total: parseFloat(day.total || 0),
|
|
count: parseInt(day.count || 0),
|
|
reasons: {}
|
|
}
|
|
}));
|
|
|
|
if (release) release();
|
|
return res.json({ stats });
|
|
}
|
|
|
|
if (eventType === 'PLACED_ORDER') {
|
|
// Order range query - daily min/max/average order values
|
|
const orderRangeQuery = `
|
|
SELECT
|
|
DATE(date_placed) as date,
|
|
COUNT(*) as orders,
|
|
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
|
|
MAX(summary_total) as largest,
|
|
AVG(summary_total) as averageOrderValue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCB)}
|
|
AND ${whereClause}
|
|
GROUP BY DATE(date_placed)
|
|
ORDER BY DATE(date_placed)
|
|
`;
|
|
|
|
const [orderRangeResults] = await connection.execute(orderRangeQuery, params);
|
|
|
|
// Format matches what frontend OrderRangeDetails expects
|
|
const stats = orderRangeResults.map(day => ({
|
|
timestamp: day.date,
|
|
date: day.date,
|
|
orders: parseInt(day.orders || 0),
|
|
orderValueRange: {
|
|
smallest: parseFloat(day.smallest || 0),
|
|
largest: parseFloat(day.largest || 0)
|
|
},
|
|
averageOrderValue: parseFloat(day.averageOrderValue || 0)
|
|
}));
|
|
|
|
if (release) release();
|
|
return res.json({ stats });
|
|
}
|
|
|
|
// Build order type filter based on orderType parameter
|
|
let orderTypeFilter = '';
|
|
if (orderType === 'pre_orders') {
|
|
orderTypeFilter = 'AND stats_waiting_preorder > 0';
|
|
} else if (orderType === 'local_pickup') {
|
|
orderTypeFilter = "AND ship_method_selected = 'localpickup'";
|
|
} else if (orderType === 'on_hold') {
|
|
orderTypeFilter = "AND ship_method_selected = 'holdit'";
|
|
}
|
|
|
|
// Daily breakdown query (optionally excludes Cherry Box orders)
|
|
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 ${getCherryBoxClause(excludeCB)} AND ${whereClause} ${orderTypeFilter}
|
|
GROUP BY DATE(date_placed)
|
|
ORDER BY DATE(date_placed)
|
|
`;
|
|
|
|
const [dailyResults] = await connection.execute(dailyQuery, params);
|
|
|
|
// Get previous period data using the same logic as main stats endpoint
|
|
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()];
|
|
}
|
|
|
|
// Get previous period daily data (optionally excludes Cherry Box orders)
|
|
const prevQuery = `
|
|
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 ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter}
|
|
GROUP BY DATE(date_placed)
|
|
`;
|
|
|
|
const [prevResults] = await connection.execute(prevQuery, prevParams);
|
|
|
|
// Create a map for quick lookup of previous period data
|
|
const prevMap = new Map();
|
|
prevResults.forEach(prev => {
|
|
const key = new Date(prev.date).toISOString().split('T')[0];
|
|
prevMap.set(key, prev);
|
|
});
|
|
|
|
// For period-to-period comparison, we need to map days by relative position
|
|
// since dates won't match exactly (e.g., current week vs previous week)
|
|
const dailyArray = dailyResults.map(day => ({
|
|
timestamp: day.date,
|
|
date: day.date,
|
|
orders: parseInt(day.orders),
|
|
revenue: parseFloat(day.revenue),
|
|
averageOrderValue: parseFloat(day.averageOrderValue || 0),
|
|
itemCount: parseInt(day.itemCount)
|
|
}));
|
|
|
|
const prevArray = prevResults.map(day => ({
|
|
orders: parseInt(day.prevOrders),
|
|
revenue: parseFloat(day.prevRevenue),
|
|
averageOrderValue: parseFloat(day.prevAvgOrderValue || 0)
|
|
}));
|
|
|
|
// Combine current and previous period data by matching relative positions
|
|
const statsWithComparison = dailyArray.map((day, index) => {
|
|
const prev = prevArray[index] || { orders: 0, revenue: 0, averageOrderValue: 0 };
|
|
|
|
return {
|
|
...day,
|
|
prevOrders: prev.orders,
|
|
prevRevenue: prev.revenue,
|
|
prevAvgOrderValue: prev.averageOrderValue
|
|
};
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Financial performance endpoint
|
|
router.get('/financials', async (req, res) => {
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
const financialWhere = whereClause.replace(/date_placed/g, 'date_change');
|
|
|
|
const formatDebugBound = (value) => {
|
|
if (!value) return 'n/a';
|
|
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
|
|
if (!parsed.isValid) {
|
|
return `invalid(${value})`;
|
|
}
|
|
return parsed.setZone(TIMEZONE).toISO();
|
|
};
|
|
|
|
console.log('[FINANCIALS] request params', {
|
|
timeRange: timeRange || 'default',
|
|
startDate,
|
|
endDate,
|
|
whereClause: financialWhere,
|
|
params,
|
|
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
|
|
});
|
|
|
|
const [totalsRows] = await connection.execute(
|
|
buildFinancialTotalsQuery(financialWhere, excludeCB),
|
|
params
|
|
);
|
|
|
|
const totals = normalizeFinancialTotals(totalsRows[0]);
|
|
|
|
console.log('[FINANCIALS] totals query result', {
|
|
rows: totalsRows.length,
|
|
totals,
|
|
});
|
|
|
|
const [trendRows] = await connection.execute(
|
|
buildFinancialTrendQuery(financialWhere, excludeCB),
|
|
params
|
|
);
|
|
|
|
const trend = trendRows.map(normalizeFinancialTrendRow);
|
|
|
|
console.log('[FINANCIALS] trend query result', {
|
|
rows: trendRows.length,
|
|
first: trend[0] || null,
|
|
last: trend[trend.length - 1] || null,
|
|
});
|
|
|
|
let previousTotals = null;
|
|
let comparison = null;
|
|
|
|
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
|
|
if (previousRange) {
|
|
console.log('[FINANCIALS] previous range params', {
|
|
timeRange: timeRange || 'default',
|
|
prevWhere: previousRange.whereClause.replace(/date_placed/g, 'date_change'),
|
|
params: previousRange.params,
|
|
boundsEastern: Array.isArray(previousRange.params)
|
|
? previousRange.params.map(formatDebugBound)
|
|
: [],
|
|
});
|
|
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
|
|
const [previousRows] = await connection.execute(
|
|
buildFinancialTotalsQuery(prevWhere, excludeCB),
|
|
previousRange.params
|
|
);
|
|
previousTotals = normalizeFinancialTotals(previousRows[0]);
|
|
comparison = {
|
|
grossSales: calculateComparison(totals.grossSales, previousTotals.grossSales),
|
|
refunds: calculateComparison(totals.refunds, previousTotals.refunds),
|
|
taxCollected: calculateComparison(totals.taxCollected, previousTotals.taxCollected),
|
|
discounts: calculateComparison(totals.discounts, previousTotals.discounts),
|
|
cogs: calculateComparison(totals.cogs, previousTotals.cogs),
|
|
income: calculateComparison(totals.income, previousTotals.income),
|
|
profit: calculateComparison(totals.profit, previousTotals.profit),
|
|
margin: calculateComparison(totals.margin, previousTotals.margin),
|
|
};
|
|
}
|
|
|
|
const trendDebugSample = trend.slice(-3).map((item) => ({
|
|
date: item.date,
|
|
timestamp: item.timestamp,
|
|
income: item.income,
|
|
grossSales: item.grossSales,
|
|
}));
|
|
|
|
const debugInfo = {
|
|
serverTimeUtc: new Date().toISOString(),
|
|
timeRange: timeRange || 'default',
|
|
params,
|
|
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
|
|
trendCount: trend.length,
|
|
trendSample: trendDebugSample,
|
|
previousRange: previousRange
|
|
? {
|
|
params: previousRange.params,
|
|
boundsEastern: Array.isArray(previousRange.params)
|
|
? previousRange.params.map(formatDebugBound)
|
|
: [],
|
|
}
|
|
: null,
|
|
};
|
|
|
|
res.json({
|
|
dateRange,
|
|
totals,
|
|
previousTotals,
|
|
comparison,
|
|
trend,
|
|
debug: debugInfo,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in /financials:', error);
|
|
res.status(500).json({ error: error.message });
|
|
} finally {
|
|
if (release) release();
|
|
}
|
|
});
|
|
|
|
// Products endpoint - replaces /api/klaviyo/events/products
|
|
router.get('/products', async (req, res) => {
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Products query (optionally excludes Cherry Box orders)
|
|
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 ${getCherryBoxClauseAliased('o', excludeCB)} 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) => {
|
|
const startTime = Date.now();
|
|
let release;
|
|
try {
|
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
|
|
|
|
// Only provide projections for incomplete periods
|
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
|
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
|
|
}
|
|
|
|
const { connection, release: releaseConn } = await getDbConnection();
|
|
release = releaseConn;
|
|
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
|
|
const now = DateTime.now().setZone(TIMEZONE);
|
|
|
|
// Get current period data
|
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Current period query (optionally excludes Cherry Box orders)
|
|
const currentQuery = `
|
|
SELECT
|
|
SUM(summary_total) as currentRevenue,
|
|
COUNT(*) as currentOrders
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
|
`;
|
|
|
|
const [currentResult] = await connection.execute(currentQuery, params);
|
|
const current = currentResult[0];
|
|
console.log(`[PROJECTION] Current period data fetched in ${Date.now() - startTime}ms`);
|
|
|
|
// Fetch pattern data in parallel for performance
|
|
const patternStart = Date.now();
|
|
const [hourlyPattern, dayOfWeekPattern, dailyStats] = await Promise.all([
|
|
getHourlyRevenuePattern(connection, excludeCB),
|
|
getDayOfWeekRevenuePattern(connection, excludeCB),
|
|
getAverageDailyRevenue(connection, excludeCB)
|
|
]);
|
|
console.log(`[PROJECTION] Pattern data fetched in ${Date.now() - patternStart}ms`);
|
|
|
|
// Calculate period progress (for logging/debugging)
|
|
const periodProgress = calculatePeriodProgress(timeRange);
|
|
|
|
// Calculate pattern-based projection
|
|
const projection = calculateSmartProjection(
|
|
timeRange,
|
|
parseFloat(current.currentRevenue || 0),
|
|
parseInt(current.currentOrders || 0),
|
|
periodProgress,
|
|
hourlyPattern,
|
|
dayOfWeekPattern,
|
|
dailyStats,
|
|
now
|
|
);
|
|
|
|
// Add some useful debug info
|
|
projection.periodProgress = periodProgress;
|
|
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
|
projection.currentOrders = parseInt(current.currentOrders || 0);
|
|
|
|
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
|
res.json(projection);
|
|
|
|
} catch (error) {
|
|
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, 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: Object.keys(states).length, // Count of unique states/regions shipped to
|
|
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) {
|
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
|
return 100;
|
|
}
|
|
|
|
const now = DateTime.now().setZone(TIMEZONE);
|
|
|
|
let range;
|
|
try {
|
|
range = timeHelpers.getRangeForTimeRange(timeRange, now);
|
|
} catch (error) {
|
|
console.error(`[STATS] Failed to derive range for ${timeRange}:`, error);
|
|
return 100;
|
|
}
|
|
|
|
if (!range?.start || !range?.end) {
|
|
return 100;
|
|
}
|
|
|
|
const total = range.end.toMillis() - range.start.toMillis();
|
|
if (total <= 0) {
|
|
return 100;
|
|
}
|
|
|
|
const elapsed = Math.min(
|
|
Math.max(now.toMillis() - range.start.toMillis(), 0),
|
|
total
|
|
);
|
|
|
|
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
|
}
|
|
|
|
function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
|
|
// Optionally join to _order to exclude Cherry Box orders
|
|
if (excludeCherryBox) {
|
|
return `
|
|
SELECT
|
|
COALESCE(SUM(r.sale_amount), 0) as grossSales,
|
|
COALESCE(SUM(r.refund_amount), 0) as refunds,
|
|
COALESCE(SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount), 0) as shippingFees,
|
|
COALESCE(SUM(r.tax_collected_amount), 0) as taxCollected,
|
|
COALESCE(SUM(r.discount_total_amount), 0) as discounts,
|
|
COALESCE(SUM(r.cogs_amount), 0) as cogs
|
|
FROM report_sales_data r
|
|
JOIN _order o ON r.order_id = o.order_id
|
|
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
|
AND r.action IN (1, 2, 3)
|
|
AND ${getCherryBoxClauseAliased('o', true)}
|
|
`;
|
|
}
|
|
return `
|
|
SELECT
|
|
COALESCE(SUM(sale_amount), 0) as grossSales,
|
|
COALESCE(SUM(refund_amount), 0) as refunds,
|
|
COALESCE(SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount), 0) as shippingFees,
|
|
COALESCE(SUM(tax_collected_amount), 0) as taxCollected,
|
|
COALESCE(SUM(discount_total_amount), 0) as discounts,
|
|
COALESCE(SUM(cogs_amount), 0) as cogs
|
|
FROM report_sales_data
|
|
WHERE ${whereClause}
|
|
AND action IN (1, 2, 3)
|
|
`;
|
|
}
|
|
|
|
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
|
|
const businessDayOffset = BUSINESS_DAY_START_HOUR;
|
|
// Optionally join to _order to exclude Cherry Box orders
|
|
if (excludeCherryBox) {
|
|
return `
|
|
SELECT
|
|
DATE_FORMAT(
|
|
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
|
|
'%Y-%m-%d'
|
|
) as businessDate,
|
|
SUM(r.sale_amount) as grossSales,
|
|
SUM(r.refund_amount) as refunds,
|
|
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
|
|
SUM(r.tax_collected_amount) as taxCollected,
|
|
SUM(r.discount_total_amount) as discounts,
|
|
SUM(r.cogs_amount) as cogs
|
|
FROM report_sales_data r
|
|
JOIN _order o ON r.order_id = o.order_id
|
|
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
|
AND r.action IN (1, 2, 3)
|
|
AND ${getCherryBoxClauseAliased('o', true)}
|
|
GROUP BY businessDate
|
|
ORDER BY businessDate ASC
|
|
`;
|
|
}
|
|
return `
|
|
SELECT
|
|
DATE_FORMAT(
|
|
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
|
|
'%Y-%m-%d'
|
|
) as businessDate,
|
|
SUM(sale_amount) as grossSales,
|
|
SUM(refund_amount) as refunds,
|
|
SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees,
|
|
SUM(tax_collected_amount) as taxCollected,
|
|
SUM(discount_total_amount) as discounts,
|
|
SUM(cogs_amount) as cogs
|
|
FROM report_sales_data
|
|
WHERE ${whereClause}
|
|
AND action IN (1, 2, 3)
|
|
GROUP BY businessDate
|
|
ORDER BY businessDate ASC
|
|
`;
|
|
}
|
|
|
|
function normalizeFinancialTotals(row = {}) {
|
|
const grossSales = parseFloat(row.grossSales || 0);
|
|
const refunds = parseFloat(row.refunds || 0);
|
|
const shippingFees = parseFloat(row.shippingFees || 0);
|
|
const taxCollected = parseFloat(row.taxCollected || 0);
|
|
const discounts = parseFloat(row.discounts || 0);
|
|
const cogs = parseFloat(row.cogs || 0);
|
|
const productNet = grossSales - refunds - discounts;
|
|
const income = productNet + shippingFees;
|
|
const profit = income - cogs;
|
|
const margin = income !== 0 ? (profit / income) * 100 : 0;
|
|
|
|
return {
|
|
grossSales,
|
|
refunds,
|
|
shippingFees,
|
|
taxCollected,
|
|
discounts,
|
|
cogs,
|
|
income,
|
|
profit,
|
|
margin,
|
|
};
|
|
}
|
|
|
|
function normalizeFinancialTrendRow(row = {}) {
|
|
const grossSales = parseFloat(row.grossSales || 0);
|
|
const refunds = parseFloat(row.refunds || 0);
|
|
const shippingFees = parseFloat(row.shippingFees || 0);
|
|
const taxCollected = parseFloat(row.taxCollected || 0);
|
|
const discounts = parseFloat(row.discounts || 0);
|
|
const cogs = parseFloat(row.cogs || 0);
|
|
const productNet = grossSales - refunds - discounts;
|
|
const income = productNet + shippingFees;
|
|
const profit = income - cogs;
|
|
const margin = income !== 0 ? (profit / income) * 100 : 0;
|
|
let timestamp = null;
|
|
let dateValue = row.businessDate || row.date || null;
|
|
|
|
const resolveBusinessDayStart = (value) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
let dt;
|
|
if (value instanceof Date) {
|
|
dt = DateTime.fromJSDate(value, { zone: TIMEZONE });
|
|
} else if (typeof value === 'string') {
|
|
dt = DateTime.fromISO(value, { zone: TIMEZONE });
|
|
if (!dt.isValid) {
|
|
dt = DateTime.fromSQL(value, { zone: TIMEZONE });
|
|
}
|
|
}
|
|
|
|
if (!dt || !dt.isValid) {
|
|
return null;
|
|
}
|
|
|
|
const hour = BUSINESS_DAY_START_HOUR;
|
|
return dt.set({
|
|
hour,
|
|
minute: 0,
|
|
second: 0,
|
|
millisecond: 0,
|
|
});
|
|
};
|
|
|
|
const businessDayStart = resolveBusinessDayStart(dateValue);
|
|
if (businessDayStart) {
|
|
timestamp = businessDayStart.toUTC().toISO();
|
|
dateValue = businessDayStart.toISO();
|
|
} else if (row.date instanceof Date) {
|
|
timestamp = new Date(row.date.getTime()).toISOString();
|
|
} else if (typeof row.date === 'string') {
|
|
timestamp = new Date(`${row.date}T00:00:00Z`).toISOString();
|
|
}
|
|
|
|
return {
|
|
date: dateValue,
|
|
grossSales,
|
|
refunds,
|
|
shippingFees,
|
|
taxCollected,
|
|
discounts,
|
|
cogs,
|
|
income,
|
|
profit,
|
|
margin,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
function calculateComparison(currentValue, previousValue) {
|
|
if (typeof previousValue !== 'number') {
|
|
return { absolute: null, percentage: null };
|
|
}
|
|
|
|
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
|
|
const percentage =
|
|
absolute !== null && previousValue !== 0
|
|
? (absolute / Math.abs(previousValue)) * 100
|
|
: null;
|
|
|
|
return { absolute, percentage };
|
|
}
|
|
|
|
function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
|
if (timeRange && timeRange !== 'custom') {
|
|
const prevTimeRange = getPreviousTimeRange(timeRange);
|
|
if (!prevTimeRange || prevTimeRange === timeRange) {
|
|
return null;
|
|
}
|
|
return getTimeRangeConditions(prevTimeRange);
|
|
}
|
|
|
|
const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate;
|
|
if (!hasCustomDates) {
|
|
return null;
|
|
}
|
|
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
|
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
const duration = end.getTime() - start.getTime();
|
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const prevEnd = new Date(start.getTime() - 1);
|
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
|
|
|
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
|
}
|
|
|
|
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
|
|
// 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()];
|
|
}
|
|
|
|
// Previous period query (optionally excludes Cherry Box orders)
|
|
const prevQuery = `
|
|
SELECT
|
|
COUNT(*) as orderCount,
|
|
SUM(summary_total) as revenue,
|
|
AVG(summary_total) as averageOrderValue
|
|
FROM _order
|
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} 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;
|
|
}
|
|
|
|
/**
|
|
* Get hourly revenue distribution pattern from last 8 weeks (same day of week)
|
|
* Returns array of 24 objects with hour and avgShare (0-1 representing % of daily revenue)
|
|
* Optimized: Uses JOIN instead of correlated subquery for O(n) instead of O(n²)
|
|
*/
|
|
async function getHourlyRevenuePattern(connection, excludeCherryBox = false) {
|
|
const now = DateTime.now().setZone(TIMEZONE);
|
|
const dayOfWeek = now.weekday; // 1=Monday, 7=Sunday (Luxon)
|
|
const mysqlDayOfWeek = dayOfWeek === 7 ? 1 : dayOfWeek + 1;
|
|
|
|
// Step 1: Get daily totals and hourly breakdowns in one efficient query
|
|
const query = `
|
|
SELECT
|
|
hourly.hour_of_day,
|
|
AVG(hourly.hour_revenue / daily.daily_revenue) as avgShare
|
|
FROM (
|
|
SELECT
|
|
DATE(date_placed) as order_date,
|
|
HOUR(date_placed) as hour_of_day,
|
|
SUM(summary_total) as hour_revenue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
AND DAYOFWEEK(date_placed) = ?
|
|
GROUP BY DATE(date_placed), HOUR(date_placed)
|
|
) hourly
|
|
JOIN (
|
|
SELECT
|
|
DATE(date_placed) as order_date,
|
|
SUM(summary_total) as daily_revenue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
AND DAYOFWEEK(date_placed) = ?
|
|
GROUP BY DATE(date_placed)
|
|
HAVING daily_revenue > 0
|
|
) daily ON hourly.order_date = daily.order_date
|
|
GROUP BY hourly.hour_of_day
|
|
ORDER BY hourly.hour_of_day
|
|
`;
|
|
|
|
const [result] = await connection.execute(query, [mysqlDayOfWeek, mysqlDayOfWeek]);
|
|
|
|
// Convert to a full 24-hour array, filling gaps with 0
|
|
const hourlyPattern = Array(24).fill(0).map((_, i) => ({ hour: i, avgShare: 0 }));
|
|
result.forEach(row => {
|
|
hourlyPattern[row.hour_of_day] = {
|
|
hour: row.hour_of_day,
|
|
avgShare: parseFloat(row.avgShare) || 0
|
|
};
|
|
});
|
|
|
|
// Normalize so shares sum to 1.0
|
|
const totalShare = hourlyPattern.reduce((sum, h) => sum + h.avgShare, 0);
|
|
if (totalShare > 0) {
|
|
hourlyPattern.forEach(h => h.avgShare = h.avgShare / totalShare);
|
|
}
|
|
|
|
return hourlyPattern;
|
|
}
|
|
|
|
/**
|
|
* Get day-of-week revenue distribution pattern from last 8 weeks
|
|
* Returns array of 7 objects with dayOfWeek (1-7, Sunday=1) and avgShare
|
|
* Optimized: Uses JOIN instead of correlated subquery
|
|
*/
|
|
async function getDayOfWeekRevenuePattern(connection, excludeCherryBox = false) {
|
|
const query = `
|
|
SELECT
|
|
daily.day_of_week,
|
|
AVG(daily.day_revenue / weekly.weekly_revenue) as avgShare
|
|
FROM (
|
|
SELECT
|
|
YEARWEEK(date_placed, 0) as year_week,
|
|
DAYOFWEEK(date_placed) as day_of_week,
|
|
SUM(summary_total) as day_revenue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
GROUP BY YEARWEEK(date_placed, 0), DAYOFWEEK(date_placed)
|
|
) daily
|
|
JOIN (
|
|
SELECT
|
|
YEARWEEK(date_placed, 0) as year_week,
|
|
SUM(summary_total) as weekly_revenue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
GROUP BY YEARWEEK(date_placed, 0)
|
|
HAVING weekly_revenue > 0
|
|
) weekly ON daily.year_week = weekly.year_week
|
|
GROUP BY daily.day_of_week
|
|
ORDER BY daily.day_of_week
|
|
`;
|
|
|
|
const [result] = await connection.execute(query);
|
|
|
|
// Convert to array indexed by MySQL day of week (1=Sunday, 2=Monday, etc.)
|
|
const weekPattern = Array(8).fill(0).map((_, i) => ({ dayOfWeek: i, avgShare: 0 }));
|
|
result.forEach(row => {
|
|
weekPattern[row.day_of_week] = {
|
|
dayOfWeek: row.day_of_week,
|
|
avgShare: parseFloat(row.avgShare) || 0
|
|
};
|
|
});
|
|
|
|
// Normalize (indices 1-7 are used, 0 is unused)
|
|
const totalShare = weekPattern.slice(1).reduce((sum, d) => sum + d.avgShare, 0);
|
|
if (totalShare > 0) {
|
|
weekPattern.forEach(d => { if (d.dayOfWeek > 0) d.avgShare = d.avgShare / totalShare; });
|
|
}
|
|
|
|
return weekPattern;
|
|
}
|
|
|
|
/**
|
|
* Get average daily revenue for projection (last 30 days, excluding today)
|
|
* Also gets same-day-of-week stats for more accurate confidence calculation
|
|
*/
|
|
async function getAverageDailyRevenue(connection, excludeCherryBox = false) {
|
|
const now = DateTime.now().setZone(TIMEZONE);
|
|
const mysqlDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1;
|
|
|
|
// Get both overall 30-day stats AND same-day-of-week stats
|
|
const query = `
|
|
SELECT
|
|
AVG(daily_revenue) as avgDailyRevenue,
|
|
STDDEV(daily_revenue) as stdDev,
|
|
COUNT(*) as dayCount,
|
|
(
|
|
SELECT AVG(day_rev) FROM (
|
|
SELECT SUM(summary_total) as day_rev
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
|
GROUP BY DATE(date_placed)
|
|
) same_day
|
|
) as sameDayAvg,
|
|
(
|
|
SELECT STDDEV(day_rev) FROM (
|
|
SELECT SUM(summary_total) as day_rev
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
|
GROUP BY DATE(date_placed)
|
|
) same_day_std
|
|
) as sameDayStdDev,
|
|
(
|
|
SELECT COUNT(*) FROM (
|
|
SELECT DATE(date_placed) as d
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
|
AND date_placed < DATE(NOW())
|
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
|
GROUP BY DATE(date_placed)
|
|
) same_day_count
|
|
) as sameDayCount
|
|
FROM (
|
|
SELECT
|
|
DATE(date_placed) as order_date,
|
|
SUM(summary_total) as daily_revenue
|
|
FROM _order
|
|
WHERE order_status > 15
|
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
|
AND date_placed < DATE(NOW())
|
|
GROUP BY DATE(date_placed)
|
|
) daily_totals
|
|
`;
|
|
|
|
const [result] = await connection.execute(query);
|
|
const row = result[0] || {};
|
|
|
|
return {
|
|
avgDailyRevenue: parseFloat(row.avgDailyRevenue) || 0,
|
|
stdDev: parseFloat(row.stdDev) || 0,
|
|
dayCount: parseInt(row.dayCount) || 0,
|
|
sameDayAvg: parseFloat(row.sameDayAvg) || 0,
|
|
sameDayStdDev: parseFloat(row.sameDayStdDev) || 0,
|
|
sameDayCount: parseInt(row.sameDayCount) || 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate meaningful confidence score based on multiple factors
|
|
* Returns score between 0-1 and breakdown of contributing factors
|
|
*/
|
|
function calculateConfidence({
|
|
expectedProgress,
|
|
currentRevenue,
|
|
patternProjection,
|
|
historicalDailyAvg,
|
|
sameDayStdDev,
|
|
sameDayCount,
|
|
stdDev,
|
|
dayCount
|
|
}) {
|
|
const factors = {};
|
|
|
|
// Factor 1: Time Progress (0-0.3)
|
|
// More time elapsed = more data = higher confidence
|
|
// Scales from 0 at 0% to 0.3 at 100%
|
|
factors.timeProgress = Math.min(0.3, expectedProgress * 0.35);
|
|
|
|
// Factor 2: Historical Predictability via Coefficient of Variation (0-0.35)
|
|
// CV = stdDev / mean - lower is more predictable
|
|
// Use same-day-of-week stats if available (more relevant)
|
|
const relevantStdDev = sameDayStdDev || stdDev || 0;
|
|
const relevantAvg = historicalDailyAvg || 1;
|
|
const cv = relevantStdDev / relevantAvg;
|
|
|
|
// CV of 0.1 (10% variation) = very predictable = full points
|
|
// CV of 0.5 (50% variation) = unpredictable = minimal points
|
|
// Scale: CV 0.1 -> 0.35, CV 0.3 -> 0.15, CV 0.5+ -> 0.05
|
|
if (cv <= 0.1) {
|
|
factors.predictability = 0.35;
|
|
} else if (cv <= 0.5) {
|
|
factors.predictability = Math.max(0.05, 0.35 - (cv - 0.1) * 0.75);
|
|
} else {
|
|
factors.predictability = 0.05;
|
|
}
|
|
|
|
// Factor 3: Tracking Accuracy (0-0.25)
|
|
// How well is today tracking the expected pattern?
|
|
// If we're at 40% progress with 38-42% of expected revenue, that's good
|
|
if (expectedProgress > 0.05 && historicalDailyAvg > 0) {
|
|
const expectedRevenueSoFar = historicalDailyAvg * expectedProgress;
|
|
const trackingRatio = currentRevenue / expectedRevenueSoFar;
|
|
|
|
// Perfect tracking (ratio = 1.0) = full points
|
|
// 20% off (ratio 0.8 or 1.2) = partial points
|
|
// 50%+ off = minimal points
|
|
const deviation = Math.abs(1 - trackingRatio);
|
|
if (deviation <= 0.1) {
|
|
factors.tracking = 0.25;
|
|
} else if (deviation <= 0.3) {
|
|
factors.tracking = 0.25 - (deviation - 0.1) * 0.5;
|
|
} else if (deviation <= 0.5) {
|
|
factors.tracking = 0.15 - (deviation - 0.3) * 0.4;
|
|
} else {
|
|
factors.tracking = 0.05;
|
|
}
|
|
} else {
|
|
// Not enough progress to judge tracking
|
|
factors.tracking = 0.1;
|
|
}
|
|
|
|
// Factor 4: Data Quality (0-0.1)
|
|
// More historical data points = more reliable pattern
|
|
const dataPoints = sameDayCount || Math.floor(dayCount / 7) || 0;
|
|
// 8 weeks of same-day data = full points, less = proportionally less
|
|
factors.dataQuality = Math.min(0.1, (dataPoints / 8) * 0.1);
|
|
|
|
// Calculate total confidence score
|
|
const score = Math.min(0.95, Math.max(0.1,
|
|
factors.timeProgress +
|
|
factors.predictability +
|
|
factors.tracking +
|
|
factors.dataQuality
|
|
));
|
|
|
|
return { score, factors };
|
|
}
|
|
|
|
/**
|
|
* Calculate pattern-based projection for different time ranges
|
|
*/
|
|
function calculateSmartProjection(
|
|
timeRange,
|
|
currentRevenue,
|
|
currentOrders,
|
|
periodProgress,
|
|
hourlyPattern,
|
|
dayOfWeekPattern,
|
|
dailyStats,
|
|
now
|
|
) {
|
|
if (periodProgress >= 100) {
|
|
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
|
|
}
|
|
|
|
const currentHour = now.hour;
|
|
const currentDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; // Convert to MySQL day (1=Sunday)
|
|
|
|
if (timeRange === 'today') {
|
|
// Calculate expected progress based on hourly pattern
|
|
// Sum up shares for all hours up to and including current hour
|
|
let expectedProgress = 0;
|
|
for (let h = 0; h <= currentHour; h++) {
|
|
expectedProgress += hourlyPattern[h]?.avgShare || 0;
|
|
}
|
|
|
|
// Adjust for partial hour (how far through current hour we are)
|
|
const minuteProgress = now.minute / 60;
|
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
|
expectedProgress = expectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
|
|
|
// Avoid division by zero and handle edge cases
|
|
if (expectedProgress <= 0.01) {
|
|
// Very early in day, use linear projection with low confidence
|
|
const linearProjection = currentRevenue / Math.max(periodProgress / 100, 0.01);
|
|
return {
|
|
projectedRevenue: linearProjection,
|
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
|
confidence: 0.1,
|
|
method: 'linear_fallback'
|
|
};
|
|
}
|
|
|
|
const patternProjection = currentRevenue / expectedProgress;
|
|
|
|
// Blend with historical average for stability early in day
|
|
// Use same-day-of-week average if available, otherwise fall back to overall average
|
|
const historicalDailyAvg = dailyStats.sameDayAvg || dailyStats.avgDailyRevenue || patternProjection;
|
|
const actualWeight = Math.pow(expectedProgress, 0.8); // More weight to actual as day progresses
|
|
const projectedRevenue = (patternProjection * actualWeight) + (historicalDailyAvg * (1 - actualWeight));
|
|
|
|
// Calculate meaningful confidence based on multiple factors
|
|
const confidence = calculateConfidence({
|
|
expectedProgress,
|
|
currentRevenue,
|
|
patternProjection,
|
|
historicalDailyAvg,
|
|
sameDayStdDev: dailyStats.sameDayStdDev,
|
|
sameDayCount: dailyStats.sameDayCount,
|
|
stdDev: dailyStats.stdDev,
|
|
dayCount: dailyStats.dayCount
|
|
});
|
|
|
|
return {
|
|
projectedRevenue,
|
|
projectedOrders: Math.round(currentOrders / expectedProgress),
|
|
confidence: confidence.score,
|
|
confidenceFactors: confidence.factors,
|
|
method: 'hourly_pattern',
|
|
debug: { expectedProgress, actualWeight, patternProjection, historicalDailyAvg }
|
|
};
|
|
}
|
|
|
|
if (timeRange === 'thisWeek') {
|
|
// Calculate revenue expected so far this week based on day-of-week pattern
|
|
// And project remaining days
|
|
|
|
// Days completed so far (Sunday = day 1 in MySQL)
|
|
// If today is Tuesday (MySQL day 3), completed days are Sunday(1) and Monday(2)
|
|
let expectedProgressSoFar = 0;
|
|
for (let d = 1; d < currentDayOfWeek; d++) {
|
|
expectedProgressSoFar += dayOfWeekPattern[d]?.avgShare || 0;
|
|
}
|
|
|
|
// Add partial progress through today using hourly pattern
|
|
let todayExpectedProgress = 0;
|
|
for (let h = 0; h <= currentHour; h++) {
|
|
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
|
|
}
|
|
const minuteProgress = now.minute / 60;
|
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
|
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
|
|
|
// Add today's partial contribution
|
|
const todayFullShare = dayOfWeekPattern[currentDayOfWeek]?.avgShare || (1/7);
|
|
expectedProgressSoFar += todayFullShare * todayExpectedProgress;
|
|
|
|
// Avoid division by zero
|
|
if (expectedProgressSoFar <= 0.01) {
|
|
return {
|
|
projectedRevenue: currentRevenue / Math.max(periodProgress / 100, 0.01),
|
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
|
confidence: 0.1,
|
|
method: 'linear_fallback'
|
|
};
|
|
}
|
|
|
|
const projectedWeekRevenue = currentRevenue / expectedProgressSoFar;
|
|
const projectedWeekOrders = Math.round(currentOrders / expectedProgressSoFar);
|
|
|
|
// Calculate meaningful confidence
|
|
const historicalWeeklyAvg = dailyStats.avgDailyRevenue * 7;
|
|
const confidence = calculateConfidence({
|
|
expectedProgress: expectedProgressSoFar,
|
|
currentRevenue,
|
|
patternProjection: projectedWeekRevenue,
|
|
historicalDailyAvg: historicalWeeklyAvg,
|
|
sameDayStdDev: dailyStats.stdDev * Math.sqrt(7), // Approximate weekly stdDev
|
|
sameDayCount: Math.floor(dailyStats.dayCount / 7),
|
|
stdDev: dailyStats.stdDev * Math.sqrt(7),
|
|
dayCount: dailyStats.dayCount
|
|
});
|
|
|
|
return {
|
|
projectedRevenue: projectedWeekRevenue,
|
|
projectedOrders: projectedWeekOrders,
|
|
confidence: confidence.score,
|
|
confidenceFactors: confidence.factors,
|
|
method: 'weekly_pattern',
|
|
debug: { expectedProgressSoFar, currentDayOfWeek, todayExpectedProgress }
|
|
};
|
|
}
|
|
|
|
if (timeRange === 'thisMonth') {
|
|
// For month projection, use days elapsed and average daily revenue
|
|
const currentDay = now.day;
|
|
const daysInMonth = now.daysInMonth;
|
|
|
|
// Calculate average daily revenue so far this month
|
|
const daysElapsed = currentDay - 1; // Full days completed
|
|
|
|
// Add partial progress through today
|
|
let todayExpectedProgress = 0;
|
|
for (let h = 0; h <= currentHour; h++) {
|
|
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
|
|
}
|
|
const minuteProgress = now.minute / 60;
|
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
|
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
|
|
|
const effectiveDaysElapsed = daysElapsed + todayExpectedProgress;
|
|
|
|
if (effectiveDaysElapsed <= 0.1) {
|
|
// Very early in month, use historical average
|
|
const projectedRevenue = dailyStats.avgDailyRevenue * daysInMonth;
|
|
return {
|
|
projectedRevenue,
|
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
|
confidence: 0.15,
|
|
method: 'historical_monthly'
|
|
};
|
|
}
|
|
|
|
// Calculate implied daily rate from current data
|
|
const impliedDailyRate = currentRevenue / effectiveDaysElapsed;
|
|
|
|
// Blend with historical average (more weight to actual data as month progresses)
|
|
const actualWeight = Math.min(0.9, effectiveDaysElapsed / 10); // Full weight after ~10 days
|
|
const blendedDailyRate = (impliedDailyRate * actualWeight) + (dailyStats.avgDailyRevenue * (1 - actualWeight));
|
|
|
|
const projectedMonthRevenue = blendedDailyRate * daysInMonth;
|
|
const projectedMonthOrders = Math.round((currentOrders / effectiveDaysElapsed) * daysInMonth);
|
|
|
|
// Calculate meaningful confidence
|
|
const historicalMonthlyAvg = dailyStats.avgDailyRevenue * daysInMonth;
|
|
const confidence = calculateConfidence({
|
|
expectedProgress: effectiveDaysElapsed / daysInMonth,
|
|
currentRevenue,
|
|
patternProjection: projectedMonthRevenue,
|
|
historicalDailyAvg: historicalMonthlyAvg,
|
|
sameDayStdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
|
|
sameDayCount: 1, // Only ~1 month of same-month data typically
|
|
stdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
|
|
dayCount: dailyStats.dayCount
|
|
});
|
|
|
|
return {
|
|
projectedRevenue: projectedMonthRevenue,
|
|
projectedOrders: projectedMonthOrders,
|
|
confidence: confidence.score,
|
|
confidenceFactors: confidence.factors,
|
|
method: 'monthly_blend',
|
|
debug: { effectiveDaysElapsed, daysInMonth, impliedDailyRate, blendedDailyRate }
|
|
};
|
|
}
|
|
|
|
// Fallback for any other case
|
|
const linearProjection = currentRevenue / (periodProgress / 100);
|
|
return {
|
|
projectedRevenue: linearProjection,
|
|
projectedOrders: Math.round(currentOrders / (periodProgress / 100)),
|
|
confidence: Math.min(0.95, Math.max(0.1, periodProgress / 100)),
|
|
method: 'linear_fallback'
|
|
};
|
|
}
|
|
|
|
// 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;
|