1872 lines
70 KiB
JavaScript
1872 lines
70 KiB
JavaScript
import fetch from 'node-fetch';
|
|
import { TimeManager } from '../utils/time.utils.js';
|
|
import { RedisService } from './redis.service.js';
|
|
import _ from 'lodash';
|
|
|
|
const METRIC_IDS = {
|
|
PLACED_ORDER: 'Y8cqcF',
|
|
SHIPPED_ORDER: 'VExpdL',
|
|
ACCOUNT_CREATED: 'TeeypV',
|
|
CANCELED_ORDER: 'YjVMNg',
|
|
NEW_BLOG_POST: 'YcxeDr',
|
|
PAYMENT_REFUNDED: 'R7XUYh'
|
|
};
|
|
|
|
export class EventsService {
|
|
constructor(apiKey, apiRevision) {
|
|
this.apiKey = apiKey;
|
|
this.apiRevision = apiRevision;
|
|
this.baseUrl = 'https://a.klaviyo.com/api';
|
|
this.timeManager = new TimeManager();
|
|
this.redisService = new RedisService();
|
|
}
|
|
|
|
async getEvents(params = {}) {
|
|
try {
|
|
// Add request debouncing
|
|
const requestKey = JSON.stringify(params);
|
|
if (this._pendingRequests && this._pendingRequests[requestKey]) {
|
|
return this._pendingRequests[requestKey];
|
|
}
|
|
|
|
// Try to get from cache first
|
|
const cacheKey = this.redisService._getCacheKey('events', params);
|
|
let cachedData = null;
|
|
try {
|
|
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
|
if (cachedData) {
|
|
cachedData.data = this._transformEvents(cachedData.data);
|
|
return cachedData;
|
|
}
|
|
} catch (cacheError) {
|
|
console.warn('[EventsService] Cache error:', cacheError);
|
|
}
|
|
|
|
this._pendingRequests = this._pendingRequests || {};
|
|
this._pendingRequests[requestKey] = (async () => {
|
|
let allEvents = [];
|
|
let nextCursor = params.pageCursor;
|
|
let pageCount = 0;
|
|
|
|
const filter = params.filter || this._buildFilter(params);
|
|
|
|
do {
|
|
const queryParams = new URLSearchParams();
|
|
if (filter) {
|
|
queryParams.append('filter', filter);
|
|
}
|
|
queryParams.append('sort', params.sort || '-datetime');
|
|
|
|
if (nextCursor) {
|
|
queryParams.append('page[cursor]', nextCursor);
|
|
}
|
|
|
|
const url = `${this.baseUrl}/events?${queryParams.toString()}`;
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
|
'revision': this.apiRevision
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
console.error('[EventsService] API Error:', errorData);
|
|
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
allEvents = allEvents.concat(responseData.data || []);
|
|
pageCount++;
|
|
|
|
nextCursor = responseData.links?.next ?
|
|
new URL(responseData.links.next).searchParams.get('page[cursor]') : null;
|
|
|
|
if (nextCursor) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
} catch (fetchError) {
|
|
console.error('[EventsService] Fetch error:', fetchError);
|
|
throw fetchError;
|
|
}
|
|
|
|
} while (nextCursor);
|
|
|
|
const transformedEvents = this._transformEvents(allEvents);
|
|
|
|
const result = {
|
|
data: transformedEvents,
|
|
meta: {
|
|
total_count: transformedEvents.length,
|
|
page_count: pageCount
|
|
}
|
|
};
|
|
|
|
try {
|
|
const ttl = this.redisService._getTTL(params.timeRange);
|
|
await this.redisService.set(`${cacheKey}:raw`, result, ttl);
|
|
} catch (cacheError) {
|
|
console.warn('[EventsService] Cache set error:', cacheError);
|
|
}
|
|
|
|
delete this._pendingRequests[requestKey];
|
|
return result;
|
|
})();
|
|
|
|
return await this._pendingRequests[requestKey];
|
|
} catch (error) {
|
|
console.error('[EventsService] Error fetching events:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
_buildFilter(params) {
|
|
const filters = [];
|
|
|
|
if (params.metricId) {
|
|
filters.push(`equals(metric_id,"${params.metricId}")`);
|
|
}
|
|
|
|
if (params.startDate && params.endDate) {
|
|
const startUtc = this.timeManager.formatForAPI(params.startDate);
|
|
const endUtc = this.timeManager.formatForAPI(params.endDate);
|
|
|
|
filters.push(`greater-or-equal(datetime,${startUtc})`);
|
|
filters.push(`less-than(datetime,${endUtc})`);
|
|
}
|
|
|
|
if (params.profileId) {
|
|
filters.push(`equals(profile_id,"${params.profileId}")`);
|
|
}
|
|
|
|
if (params.customFilters) {
|
|
filters.push(...params.customFilters);
|
|
}
|
|
|
|
return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null;
|
|
}
|
|
|
|
async getEventsByTimeRange(timeRange, options = {}) {
|
|
const range = this.timeManager.getDateRange(timeRange);
|
|
if (!range) {
|
|
throw new Error('Invalid time range specified');
|
|
}
|
|
|
|
const params = {
|
|
timeRange,
|
|
startDate: range.start.toISO(),
|
|
endDate: range.end.toISO(),
|
|
metricId: options.metricId
|
|
};
|
|
|
|
// Try to get from cache first
|
|
const cacheKey = this.redisService._getCacheKey('events', params);
|
|
let cachedData = null;
|
|
try {
|
|
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
|
if (cachedData) {
|
|
// Transform cached events
|
|
cachedData.data = this._transformEvents(cachedData.data);
|
|
return cachedData;
|
|
}
|
|
} catch (cacheError) {
|
|
console.warn('[EventsService] Cache error:', cacheError);
|
|
// Continue with API request if cache fails
|
|
}
|
|
|
|
return this.getEvents(params);
|
|
}
|
|
|
|
async calculatePeriodStats(params = {}) {
|
|
try {
|
|
// Add request debouncing
|
|
const requestKey = JSON.stringify(params);
|
|
if (this._pendingStatRequests && this._pendingStatRequests[requestKey]) {
|
|
return this._pendingStatRequests[requestKey];
|
|
}
|
|
|
|
// Get period dates
|
|
let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd;
|
|
if (params.startDate && params.endDate) {
|
|
periodStart = this.timeManager.toDateTime(params.startDate);
|
|
periodEnd = this.timeManager.toDateTime(params.endDate);
|
|
const duration = periodEnd.diff(periodStart);
|
|
prevPeriodStart = periodStart.minus(duration);
|
|
prevPeriodEnd = periodStart.minus({ milliseconds: 1 });
|
|
} else if (params.timeRange) {
|
|
const range = this.timeManager.getDateRange(params.timeRange);
|
|
periodStart = range.start;
|
|
periodEnd = range.end;
|
|
const duration = periodEnd.diff(periodStart);
|
|
prevPeriodStart = periodStart.minus(duration);
|
|
prevPeriodEnd = periodStart.minus({ milliseconds: 1 });
|
|
}
|
|
|
|
// Load both current and previous period data
|
|
const [orderData, shippedData, refundData, canceledData, prevPeriodData] = await Promise.all([
|
|
this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.PLACED_ORDER
|
|
}),
|
|
this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.SHIPPED_ORDER
|
|
}),
|
|
this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.PAYMENT_REFUNDED
|
|
}),
|
|
this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.CANCELED_ORDER
|
|
}),
|
|
this.getEvents({
|
|
// Only pass through non-date related params for previous period
|
|
..._.omit(params, ['timeRange', 'startDate', 'endDate']),
|
|
metricId: METRIC_IDS.PLACED_ORDER,
|
|
startDate: prevPeriodStart.toISO(),
|
|
endDate: prevPeriodEnd.toISO()
|
|
})
|
|
]);
|
|
|
|
// Add debug logging
|
|
console.log('[EventsService] Previous period request:', {
|
|
params: _.omit(params, ['timeRange', 'startDate', 'endDate']),
|
|
dates: {
|
|
start: prevPeriodStart.toISO(),
|
|
end: prevPeriodEnd.toISO()
|
|
},
|
|
responseLength: prevPeriodData?.data?.length
|
|
});
|
|
|
|
// Transform all data
|
|
const transformedOrders = this._transformEvents(orderData.data);
|
|
const transformedShipped = this._transformEvents(shippedData.data);
|
|
const transformedRefunds = this._transformEvents(refundData.data);
|
|
const transformedCanceled = this._transformEvents(canceledData.data);
|
|
const transformedPrevPeriod = this._transformEvents(prevPeriodData.data);
|
|
|
|
// Calculate previous period stats
|
|
const prevPeriodRevenue = transformedPrevPeriod.reduce((sum, order) => {
|
|
const props = order.event_properties || {};
|
|
return sum + Number(props.TotalAmount || 0);
|
|
}, 0);
|
|
|
|
const prevPeriodOrders = transformedPrevPeriod.length;
|
|
const prevPeriodAOV = prevPeriodOrders > 0 ? prevPeriodRevenue / prevPeriodOrders : 0;
|
|
|
|
// Calculate stats with all data available
|
|
const stats = {
|
|
orderCount: 0,
|
|
revenue: 0,
|
|
averageOrderValue: 0,
|
|
itemCount: 0,
|
|
prevPeriodRevenue,
|
|
projectedRevenue: 0,
|
|
periodProgress: 0,
|
|
dailyData: [],
|
|
products: {
|
|
list: [],
|
|
total: 0,
|
|
status: {
|
|
backordered: 0,
|
|
inStock: 0,
|
|
outOfStock: 0,
|
|
preorder: 0
|
|
}
|
|
},
|
|
shipping: {
|
|
shippedCount: 0,
|
|
locations: {
|
|
byState: [],
|
|
byCountry: []
|
|
},
|
|
methods: {},
|
|
methodPercentages: {},
|
|
totalRevenue: 0,
|
|
averageShipTime: 0,
|
|
totalShipTime: 0
|
|
},
|
|
refunds: {
|
|
total: 0,
|
|
count: 0,
|
|
reasons: {},
|
|
items: [],
|
|
averageAmount: 0
|
|
},
|
|
canceledOrders: {
|
|
total: 0,
|
|
count: 0,
|
|
reasons: {},
|
|
items: [],
|
|
averageAmount: 0
|
|
},
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
brands: {
|
|
total: 0,
|
|
list: [],
|
|
topBrands: [],
|
|
totalRevenue: 0,
|
|
averageOrderValue: 0
|
|
},
|
|
categories: {
|
|
total: 0,
|
|
list: [],
|
|
topCategories: [],
|
|
totalRevenue: 0,
|
|
averageOrderValue: 0
|
|
},
|
|
hourlyOrders: Array(24).fill(0),
|
|
peakOrderHour: null,
|
|
bestRevenueDay: null,
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
};
|
|
|
|
// Calculate period progress
|
|
if (periodStart && periodEnd) {
|
|
const totalDuration = periodEnd.diff(periodStart);
|
|
const elapsedDuration = this.timeManager.getNow().diff(periodStart);
|
|
stats.periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
|
|
}
|
|
|
|
// Process orders
|
|
const brandMap = new Map();
|
|
const categoryMap = new Map();
|
|
const dailyOrderCounts = {};
|
|
const dailyStats = new Map();
|
|
|
|
// Track best day stats
|
|
let bestDay = {
|
|
date: null,
|
|
displayDate: null,
|
|
amount: 0,
|
|
orderCount: 0
|
|
};
|
|
|
|
// Initialize daily stats for the entire date range
|
|
if (periodStart && periodEnd) {
|
|
let currentDate = periodStart;
|
|
while (currentDate <= periodEnd) {
|
|
const dateKey = currentDate.toFormat('yyyy-MM-dd');
|
|
dailyStats.set(dateKey, {
|
|
date: currentDate.toISO(), // ISO format for chart library
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
averageOrderValue: 0,
|
|
averageItemsPerOrder: 0,
|
|
hourlyOrders: Array(24).fill(0),
|
|
refunds: { total: 0, count: 0, reasons: {}, items: [] },
|
|
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
});
|
|
currentDate = currentDate.plus({ days: 1 });
|
|
}
|
|
}
|
|
|
|
// Track peak hour stats
|
|
let maxHourCount = 0;
|
|
let peakHour = 0;
|
|
|
|
for (const order of transformedOrders) {
|
|
const props = order.event_properties || {};
|
|
const items = props.Items || [];
|
|
const totalAmount = Number(props.TotalAmount || 0);
|
|
const datetime = this.timeManager.toDateTime(order.attributes?.datetime);
|
|
const orderId = props.OrderId;
|
|
|
|
// Update order counts and revenue
|
|
stats.orderCount++;
|
|
stats.revenue += totalAmount;
|
|
stats.itemCount += items.length;
|
|
|
|
// Calculate running AOV
|
|
stats.averageOrderValue = stats.revenue / stats.orderCount;
|
|
|
|
// Track daily stats
|
|
if (datetime) {
|
|
const dateKey = datetime.toFormat('yyyy-MM-dd');
|
|
const hourOfDay = datetime.hour;
|
|
|
|
// Initialize stats for this date if it doesn't exist
|
|
if (!dailyStats.has(dateKey)) {
|
|
dailyStats.set(dateKey, {
|
|
date: datetime.toISO(), // ISO format for chart library
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
averageOrderValue: 0,
|
|
averageItemsPerOrder: 0,
|
|
hourlyOrders: Array(24).fill(0),
|
|
refunds: { total: 0, count: 0, reasons: {}, items: [] },
|
|
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const dayStats = dailyStats.get(dateKey);
|
|
|
|
// Update daily stats
|
|
dayStats.revenue += totalAmount;
|
|
dayStats.orders++;
|
|
dayStats.itemCount += items.length;
|
|
dayStats.hourlyOrders[hourOfDay]++;
|
|
dayStats.averageOrderValue = dayStats.revenue / dayStats.orders;
|
|
dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders;
|
|
|
|
// Track best day
|
|
if (dayStats.revenue > bestDay.amount) {
|
|
bestDay = {
|
|
date: dateKey,
|
|
displayDate: datetime.toFormat('LLL d, yyyy'),
|
|
amount: dayStats.revenue,
|
|
orderCount: dayStats.orders
|
|
};
|
|
}
|
|
|
|
// Track daily order value range
|
|
if (totalAmount > dayStats.orderValueRange.largest) {
|
|
dayStats.orderValueRange.largest = totalAmount;
|
|
dayStats.orderValueRange.largestOrderId = orderId;
|
|
}
|
|
if (totalAmount > 0) {
|
|
if (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest) {
|
|
dayStats.orderValueRange.smallest = totalAmount;
|
|
dayStats.orderValueRange.smallestOrderId = orderId;
|
|
}
|
|
|
|
// Track order value distribution
|
|
if (totalAmount < 25) {
|
|
dayStats.orderValueRange.distribution.under25.count++;
|
|
dayStats.orderValueRange.distribution.under25.total += totalAmount;
|
|
} else if (totalAmount < 50) {
|
|
dayStats.orderValueRange.distribution.under50.count++;
|
|
dayStats.orderValueRange.distribution.under50.total += totalAmount;
|
|
} else if (totalAmount < 100) {
|
|
dayStats.orderValueRange.distribution.under100.count++;
|
|
dayStats.orderValueRange.distribution.under100.total += totalAmount;
|
|
} else if (totalAmount < 200) {
|
|
dayStats.orderValueRange.distribution.under200.count++;
|
|
dayStats.orderValueRange.distribution.under200.total += totalAmount;
|
|
} else {
|
|
dayStats.orderValueRange.distribution.over200.count++;
|
|
dayStats.orderValueRange.distribution.over200.total += totalAmount;
|
|
}
|
|
}
|
|
|
|
// Track order types
|
|
if (props.HasPreorder) {
|
|
dayStats.orderTypes.preOrders.count++;
|
|
dayStats.orderTypes.preOrders.value += totalAmount;
|
|
dayStats.orderTypes.preOrders.percentage = (dayStats.orderTypes.preOrders.count / dayStats.orders) * 100;
|
|
dayStats.orderTypes.preOrders.averageValue = dayStats.orderTypes.preOrders.value / dayStats.orderTypes.preOrders.count;
|
|
}
|
|
if (props.LocalPickup) {
|
|
dayStats.orderTypes.localPickup.count++;
|
|
dayStats.orderTypes.localPickup.value += totalAmount;
|
|
dayStats.orderTypes.localPickup.percentage = (dayStats.orderTypes.localPickup.count / dayStats.orders) * 100;
|
|
dayStats.orderTypes.localPickup.averageValue = dayStats.orderTypes.localPickup.value / dayStats.orderTypes.localPickup.count;
|
|
}
|
|
if (props.IsOnHold) {
|
|
dayStats.orderTypes.heldItems.count++;
|
|
dayStats.orderTypes.heldItems.value += totalAmount;
|
|
dayStats.orderTypes.heldItems.percentage = (dayStats.orderTypes.heldItems.count / dayStats.orders) * 100;
|
|
dayStats.orderTypes.heldItems.averageValue = dayStats.orderTypes.heldItems.value / dayStats.orderTypes.heldItems.count;
|
|
}
|
|
if (props.HasDigiItem) {
|
|
dayStats.orderTypes.digital.count++;
|
|
dayStats.orderTypes.digital.value += totalAmount;
|
|
dayStats.orderTypes.digital.percentage = (dayStats.orderTypes.digital.count / dayStats.orders) * 100;
|
|
dayStats.orderTypes.digital.averageValue = dayStats.orderTypes.digital.value / dayStats.orderTypes.digital.count;
|
|
}
|
|
if (props.HasDigitalGC) {
|
|
dayStats.orderTypes.giftCard.count++;
|
|
dayStats.orderTypes.giftCard.value += totalAmount;
|
|
dayStats.orderTypes.giftCard.percentage = (dayStats.orderTypes.giftCard.count / dayStats.orders) * 100;
|
|
dayStats.orderTypes.giftCard.averageValue = dayStats.orderTypes.giftCard.value / dayStats.orderTypes.giftCard.count;
|
|
}
|
|
|
|
// Update hourly stats for peak hour calculation
|
|
if (dayStats.hourlyOrders[hourOfDay] > maxHourCount) {
|
|
maxHourCount = dayStats.hourlyOrders[hourOfDay];
|
|
peakHour = hourOfDay;
|
|
}
|
|
|
|
dailyStats.set(dateKey, dayStats);
|
|
}
|
|
|
|
// Track order value range
|
|
if (totalAmount > stats.orderValueRange.largest) {
|
|
stats.orderValueRange.largest = totalAmount;
|
|
stats.orderValueRange.largestOrderId = orderId;
|
|
}
|
|
if (totalAmount > 0) {
|
|
if (stats.orderValueRange.smallest === 0 || totalAmount < stats.orderValueRange.smallest) {
|
|
stats.orderValueRange.smallest = totalAmount;
|
|
stats.orderValueRange.smallestOrderId = orderId;
|
|
}
|
|
|
|
// Track order value distribution
|
|
if (totalAmount < 25) {
|
|
stats.orderValueRange.distribution.under25.count++;
|
|
stats.orderValueRange.distribution.under25.total += totalAmount;
|
|
} else if (totalAmount < 50) {
|
|
stats.orderValueRange.distribution.under50.count++;
|
|
stats.orderValueRange.distribution.under50.total += totalAmount;
|
|
} else if (totalAmount < 100) {
|
|
stats.orderValueRange.distribution.under100.count++;
|
|
stats.orderValueRange.distribution.under100.total += totalAmount;
|
|
} else if (totalAmount < 200) {
|
|
stats.orderValueRange.distribution.under200.count++;
|
|
stats.orderValueRange.distribution.under200.total += totalAmount;
|
|
} else {
|
|
stats.orderValueRange.distribution.over200.count++;
|
|
stats.orderValueRange.distribution.over200.total += totalAmount;
|
|
}
|
|
}
|
|
|
|
// Track order types with values
|
|
if (props.HasPreorder) {
|
|
stats.orderTypes.preOrders.count++;
|
|
stats.orderTypes.preOrders.value += totalAmount;
|
|
stats.orderTypes.preOrders.items.push({ orderId, amount: totalAmount, items });
|
|
stats.orderTypes.preOrders.percentage = (stats.orderTypes.preOrders.count / stats.orderCount) * 100;
|
|
stats.orderTypes.preOrders.averageValue = stats.orderTypes.preOrders.value / stats.orderTypes.preOrders.count;
|
|
}
|
|
if (props.LocalPickup) {
|
|
stats.orderTypes.localPickup.count++;
|
|
stats.orderTypes.localPickup.value += totalAmount;
|
|
stats.orderTypes.localPickup.items.push({ orderId, amount: totalAmount, items });
|
|
stats.orderTypes.localPickup.percentage = (stats.orderTypes.localPickup.count / stats.orderCount) * 100;
|
|
stats.orderTypes.localPickup.averageValue = stats.orderTypes.localPickup.value / stats.orderTypes.localPickup.count;
|
|
}
|
|
if (props.IsOnHold) {
|
|
stats.orderTypes.heldItems.count++;
|
|
stats.orderTypes.heldItems.value += totalAmount;
|
|
stats.orderTypes.heldItems.items.push({ orderId, amount: totalAmount, items });
|
|
stats.orderTypes.heldItems.percentage = (stats.orderTypes.heldItems.count / stats.orderCount) * 100;
|
|
stats.orderTypes.heldItems.averageValue = stats.orderTypes.heldItems.value / stats.orderTypes.heldItems.count;
|
|
}
|
|
if (props.HasDigiItem) {
|
|
stats.orderTypes.digital.count++;
|
|
stats.orderTypes.digital.value += totalAmount;
|
|
stats.orderTypes.digital.items.push({ orderId, amount: totalAmount, items });
|
|
stats.orderTypes.digital.percentage = (stats.orderTypes.digital.count / stats.orderCount) * 100;
|
|
stats.orderTypes.digital.averageValue = stats.orderTypes.digital.value / stats.orderTypes.digital.count;
|
|
}
|
|
if (props.HasDigitalGC) {
|
|
stats.orderTypes.giftCard.count++;
|
|
stats.orderTypes.giftCard.value += totalAmount;
|
|
stats.orderTypes.giftCard.items.push({ orderId, amount: totalAmount, items });
|
|
stats.orderTypes.giftCard.percentage = (stats.orderTypes.giftCard.count / stats.orderCount) * 100;
|
|
stats.orderTypes.giftCard.averageValue = stats.orderTypes.giftCard.value / stats.orderTypes.giftCard.count;
|
|
}
|
|
|
|
// Track hourly and daily stats
|
|
if (datetime) {
|
|
const hour = datetime.hour;
|
|
stats.hourlyOrders[hour]++;
|
|
}
|
|
|
|
// Process products and related data
|
|
for (const item of items) {
|
|
const productId = item.ProductID;
|
|
const quantity = Number(item.Quantity || item.QuantityOrdered || 1);
|
|
const revenue = Number(item.RowTotal || (item.ItemPrice * quantity) || 0);
|
|
|
|
// Track item status
|
|
switch(item.ItemStatus?.toLowerCase()) {
|
|
case 'backordered':
|
|
stats.products.status.backordered++;
|
|
break;
|
|
case 'out of stock':
|
|
stats.products.status.outOfStock++;
|
|
break;
|
|
case 'preorder':
|
|
stats.products.status.preorder++;
|
|
break;
|
|
default:
|
|
stats.products.status.inStock++;
|
|
}
|
|
|
|
// Update product stats
|
|
const existingProduct = stats.products.list.find(p => p.id === productId);
|
|
if (existingProduct) {
|
|
existingProduct.totalQuantity += quantity;
|
|
existingProduct.totalRevenue += revenue;
|
|
existingProduct.orderCount++;
|
|
existingProduct.orders.add(orderId);
|
|
} else {
|
|
stats.products.list.push({
|
|
id: productId,
|
|
sku: item.SKU,
|
|
name: item.ProductName,
|
|
brand: item.Brand,
|
|
price: Number(item.ItemPrice || 0),
|
|
ImgThumb: item.ImgThumb,
|
|
totalQuantity: quantity,
|
|
totalRevenue: revenue,
|
|
orderCount: 1,
|
|
orders: new Set([orderId]),
|
|
categories: item.Categories || [],
|
|
status: item.ItemStatus || 'In Stock'
|
|
});
|
|
}
|
|
|
|
// Update brand stats
|
|
if (item.Brand) {
|
|
const brand = brandMap.get(item.Brand) || {
|
|
name: item.Brand,
|
|
quantity: 0,
|
|
revenue: 0,
|
|
products: new Set()
|
|
};
|
|
brand.quantity += quantity;
|
|
brand.revenue += revenue;
|
|
brand.products.add(productId);
|
|
brandMap.set(item.Brand, brand);
|
|
}
|
|
|
|
// Update category stats
|
|
if (item.Categories) {
|
|
for (const category of item.Categories) {
|
|
const categoryStats = categoryMap.get(category) || {
|
|
name: category,
|
|
quantity: 0,
|
|
revenue: 0,
|
|
products: new Set()
|
|
};
|
|
categoryStats.quantity += quantity;
|
|
categoryStats.revenue += revenue;
|
|
categoryStats.products.add(productId);
|
|
categoryMap.set(category, categoryStats);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// After processing all orders
|
|
if (stats.orderCount > 0) {
|
|
stats.averageOrderValue = stats.revenue / stats.orderCount;
|
|
stats.averageItemsPerOrder = stats.itemCount / stats.orderCount;
|
|
}
|
|
|
|
// Calculate projected revenue for incomplete periods
|
|
if (periodStart && periodEnd) {
|
|
const totalDuration = periodEnd.diff(periodStart);
|
|
const elapsedDuration = this.timeManager.getNow().diff(periodStart);
|
|
const periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
|
|
|
|
if (periodProgress > 0 && periodProgress < 100) {
|
|
stats.projectedRevenue = (stats.revenue / (periodProgress / 100));
|
|
} else {
|
|
stats.projectedRevenue = stats.revenue;
|
|
}
|
|
stats.periodProgress = periodProgress;
|
|
}
|
|
|
|
// Calculate trend data only for revenue
|
|
if (prevPeriodRevenue > 0) {
|
|
stats.trend = {
|
|
revenue: ((stats.revenue - prevPeriodRevenue) / prevPeriodRevenue) * 100
|
|
};
|
|
}
|
|
|
|
// Process shipped orders with better formatting
|
|
const shippingMethodMap = new Map();
|
|
const locationMap = new Map();
|
|
const stateMap = new Map();
|
|
const countryMap = new Map();
|
|
|
|
for (const shipped of transformedShipped) {
|
|
const props = shipped.event_properties || {};
|
|
stats.shipping.shippedCount++;
|
|
|
|
// Track shipping methods
|
|
const method = props.ShipMethod || props.ShippingMethod || 'Unknown';
|
|
const currentMethodCount = shippingMethodMap.get(method) || 0;
|
|
shippingMethodMap.set(method, currentMethodCount + 1);
|
|
|
|
// Track locations by state and country
|
|
const state = props.ShippingState?.trim() || 'Unknown State';
|
|
const country = props.ShippingCountry?.trim() || 'Unknown Country';
|
|
|
|
// Track unique locations
|
|
const locationKey = `${state}-${country}`;
|
|
locationMap.set(locationKey, true);
|
|
|
|
// Track by state
|
|
const stateStats = stateMap.get(state) || { count: 0, country };
|
|
stateStats.count++;
|
|
stateMap.set(state, stateStats);
|
|
|
|
// Track by country
|
|
const countryStats = countryMap.get(country) || { count: 0, states: new Set() };
|
|
countryStats.count++;
|
|
countryStats.states.add(state);
|
|
countryMap.set(country, countryStats);
|
|
}
|
|
|
|
// Format shipping methods
|
|
stats.shipping.methods = Object.fromEntries(shippingMethodMap);
|
|
stats.shipping.methodPercentages = {};
|
|
stats.shipping.methodStats = [];
|
|
shippingMethodMap.forEach((count, method) => {
|
|
const percentage = (count / stats.shipping.shippedCount) * 100;
|
|
stats.shipping.methodPercentages[method] = percentage;
|
|
stats.shipping.methodStats.push({
|
|
name: method,
|
|
value: count,
|
|
percentage
|
|
});
|
|
});
|
|
stats.shipping.methodStats.sort((a, b) => b.value - a.value);
|
|
|
|
// Format locations by state and country
|
|
stats.shipping.locations = {
|
|
total: locationMap.size,
|
|
byState: Array.from(stateMap.entries())
|
|
.map(([state, data]) => ({
|
|
state,
|
|
country: data.country,
|
|
count: data.count,
|
|
percentage: (data.count / stats.shipping.shippedCount) * 100
|
|
}))
|
|
.sort((a, b) => b.count - a.count),
|
|
byCountry: Array.from(countryMap.entries())
|
|
.map(([country, data]) => ({
|
|
country,
|
|
count: data.count,
|
|
states: Array.from(data.states),
|
|
percentage: (data.count / stats.shipping.shippedCount) * 100
|
|
}))
|
|
.sort((a, b) => b.count - a.count)
|
|
};
|
|
|
|
// Process refunds with more detail
|
|
for (const refund of transformedRefunds) {
|
|
const props = refund.event_properties || {};
|
|
const amount = Number(props.PaymentAmount || 0);
|
|
const reason = props.CancelReason || props.OrderMessage || 'No reason provided';
|
|
const datetime = this.timeManager.toDateTime(refund.attributes?.datetime);
|
|
const orderId = props.OrderId || props.FromOrder;
|
|
|
|
stats.refunds.total += amount;
|
|
stats.refunds.count++;
|
|
stats.refunds.reasons[reason] = (stats.refunds.reasons[reason] || 0) + 1;
|
|
stats.refunds.items.push({
|
|
orderId,
|
|
amount,
|
|
reason,
|
|
datetime: datetime?.toISO()
|
|
});
|
|
stats.refunds.averageAmount = stats.refunds.total / stats.refunds.count;
|
|
|
|
// Track daily refunds
|
|
if (datetime) {
|
|
const dateKey = datetime.toFormat('yyyy-MM-dd');
|
|
// Initialize stats for this date if it doesn't exist
|
|
if (!dailyStats.has(dateKey)) {
|
|
dailyStats.set(dateKey, {
|
|
date: datetime.toISO(), // ISO format for chart library
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
averageOrderValue: 0,
|
|
averageItemsPerOrder: 0,
|
|
hourlyOrders: Array(24).fill(0),
|
|
refunds: { total: 0, count: 0, reasons: {}, items: [] },
|
|
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
const dayStats = dailyStats.get(dateKey);
|
|
dayStats.refunds.total += amount;
|
|
dayStats.refunds.count++;
|
|
dayStats.refunds.reasons[reason] = (dayStats.refunds.reasons[reason] || 0) + 1;
|
|
dailyStats.set(dateKey, dayStats);
|
|
}
|
|
}
|
|
|
|
// Process canceled orders with more detail
|
|
for (const canceled of transformedCanceled) {
|
|
const props = canceled.event_properties || {};
|
|
const amount = Number(props.TotalAmount || 0);
|
|
const reason = props.CancelReason || props.OrderMessage || 'No reason provided';
|
|
const datetime = this.timeManager.toDateTime(canceled.attributes?.datetime);
|
|
const orderId = props.OrderId || props.FromOrder;
|
|
|
|
stats.canceledOrders = stats.canceledOrders || { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 };
|
|
stats.canceledOrders.total += amount;
|
|
stats.canceledOrders.count++;
|
|
stats.canceledOrders.reasons[reason] = (stats.canceledOrders.reasons[reason] || 0) + 1;
|
|
stats.canceledOrders.items.push({
|
|
orderId,
|
|
amount,
|
|
reason,
|
|
datetime: datetime?.toISO()
|
|
});
|
|
stats.canceledOrders.averageAmount = stats.canceledOrders.total / stats.canceledOrders.count;
|
|
|
|
// Track daily cancellations
|
|
if (datetime) {
|
|
const dateKey = datetime.toFormat('yyyy-MM-dd');
|
|
// Initialize stats for this date if it doesn't exist
|
|
if (!dailyStats.has(dateKey)) {
|
|
dailyStats.set(dateKey, {
|
|
date: datetime.toISO(), // ISO format for chart library
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
averageOrderValue: 0,
|
|
averageItemsPerOrder: 0,
|
|
hourlyOrders: Array(24).fill(0),
|
|
refunds: { total: 0, count: 0, reasons: {}, items: [] },
|
|
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
const dayStats = dailyStats.get(dateKey);
|
|
dayStats.canceledOrders.total += amount;
|
|
dayStats.canceledOrders.count++;
|
|
dayStats.canceledOrders.reasons[reason] = (dayStats.canceledOrders.reasons[reason] || 0) + 1;
|
|
dailyStats.set(dateKey, dayStats);
|
|
}
|
|
}
|
|
|
|
// Set peak hour stats
|
|
if (maxHourCount > 0) {
|
|
stats.peakOrderHour = {
|
|
hour: peakHour,
|
|
count: maxHourCount,
|
|
displayHour: this._formatHour(peakHour),
|
|
hourData: stats.hourlyOrders.map((count, hour) => ({
|
|
hour,
|
|
displayHour: this._formatHour(hour),
|
|
count,
|
|
percentage: (count / stats.orderCount) * 100
|
|
}))
|
|
};
|
|
}
|
|
|
|
// Set best day stats
|
|
if (bestDay.date) {
|
|
stats.bestRevenueDay = {
|
|
...bestDay,
|
|
dialogType: 'revenue', // Add dialog type for frontend
|
|
dialogTitle: 'Revenue Details' // Add dialog title for frontend
|
|
};
|
|
}
|
|
|
|
// Process products with status tracking
|
|
stats.products.list = stats.products.list.map(product => ({
|
|
...product,
|
|
averageOrderValue: product.totalRevenue / product.orderCount,
|
|
orders: Array.from(product.orders)
|
|
}));
|
|
stats.products.list.sort((a, b) => b.totalRevenue - a.totalRevenue);
|
|
stats.products.total = stats.products.list.length;
|
|
|
|
// Format brands with more detail and period-specific data
|
|
stats.brands.list = Array.from(brandMap.values())
|
|
.map(brand => {
|
|
const brandStats = {
|
|
name: brand.name,
|
|
count: brand.quantity,
|
|
revenue: brand.revenue,
|
|
productCount: brand.products.size,
|
|
percentage: (brand.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue
|
|
averageOrderValue: brand.revenue / brand.quantity,
|
|
products: Array.from(brand.products)
|
|
};
|
|
// Add display formatting for charts
|
|
brandStats.tooltipLabel = `${brand.name}\nRevenue: $${brand.revenue.toFixed(2)}\nItems: ${brand.quantity}`;
|
|
return brandStats;
|
|
})
|
|
.sort((a, b) => b.revenue - a.revenue);
|
|
|
|
stats.brands.topBrands = stats.brands.list.slice(0, 10);
|
|
stats.brands.totalRevenue = stats.revenue; // Use period revenue
|
|
stats.brands.averageOrderValue = stats.revenue / stats.itemCount;
|
|
stats.brands.total = stats.brands.list.length;
|
|
|
|
// Format categories with more detail and period-specific data
|
|
stats.categories.list = Array.from(categoryMap.values())
|
|
.map(category => {
|
|
const categoryStats = {
|
|
name: category.name,
|
|
count: category.quantity,
|
|
revenue: category.revenue,
|
|
productCount: category.products.size,
|
|
percentage: (category.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue
|
|
averageOrderValue: category.revenue / category.quantity,
|
|
products: Array.from(category.products)
|
|
};
|
|
// Add display formatting for charts
|
|
categoryStats.tooltipLabel = `${category.name}\nRevenue: $${category.revenue.toFixed(2)}\nItems: ${category.quantity}`;
|
|
return categoryStats;
|
|
})
|
|
.sort((a, b) => b.revenue - a.revenue);
|
|
|
|
stats.categories.topCategories = stats.categories.list.slice(0, 10);
|
|
stats.categories.totalRevenue = stats.revenue; // Use period revenue
|
|
stats.categories.averageOrderValue = stats.revenue / stats.itemCount;
|
|
stats.categories.total = stats.categories.list.length;
|
|
|
|
// Remove pie chart labels and create a key
|
|
stats.brands.key = stats.brands.list.map(brand => ({
|
|
name: brand.name,
|
|
color: this._getColorForBrand(brand.name)
|
|
}));
|
|
stats.categories.key = stats.categories.list.map(category => ({
|
|
name: category.name,
|
|
color: this._getColorForCategory(category.name)
|
|
}));
|
|
|
|
// Set peak hour stats with proper formatting
|
|
if (maxHourCount > 0) {
|
|
stats.peakOrderHour = {
|
|
hour: peakHour,
|
|
count: maxHourCount,
|
|
displayHour: this._formatHour(peakHour),
|
|
hourData: stats.hourlyOrders.map((count, hour) => ({
|
|
hour,
|
|
displayHour: this._formatHour(hour),
|
|
count,
|
|
percentage: (count / stats.orderCount) * 100
|
|
}))
|
|
};
|
|
}
|
|
|
|
// Set best day stats with link to revenue dialog
|
|
if (bestDay.date) {
|
|
stats.bestRevenueDay = {
|
|
...bestDay,
|
|
dialogType: 'revenue', // Add dialog type for frontend
|
|
dialogTitle: 'Revenue Details' // Add dialog title for frontend
|
|
};
|
|
}
|
|
|
|
// Add daily stats for order types
|
|
const orderTypeStats = {
|
|
preOrders: { dailyData: [] },
|
|
localPickup: { dailyData: [] },
|
|
heldItems: { dailyData: [] }
|
|
};
|
|
|
|
// Process daily stats for order types
|
|
stats.dailyData.forEach(day => {
|
|
// Pre-orders daily data
|
|
orderTypeStats.preOrders.dailyData.push({
|
|
date: day.date,
|
|
count: day.orderTypes.preOrders.count,
|
|
value: day.orderTypes.preOrders.value,
|
|
percentage: day.orderTypes.preOrders.percentage,
|
|
totalOrders: day.orders
|
|
});
|
|
|
|
// Local pickup daily data
|
|
orderTypeStats.localPickup.dailyData.push({
|
|
date: day.date,
|
|
count: day.orderTypes.localPickup.count,
|
|
value: day.orderTypes.localPickup.value,
|
|
percentage: day.orderTypes.localPickup.percentage,
|
|
totalOrders: day.orders
|
|
});
|
|
|
|
// Held items daily data
|
|
orderTypeStats.heldItems.dailyData.push({
|
|
date: day.date,
|
|
count: day.orderTypes.heldItems.count,
|
|
value: day.orderTypes.heldItems.value,
|
|
percentage: day.orderTypes.heldItems.percentage,
|
|
totalOrders: day.orders
|
|
});
|
|
});
|
|
|
|
// Add order type stats to main stats object
|
|
stats.orderTypes.preOrders.dailyData = orderTypeStats.preOrders.dailyData;
|
|
stats.orderTypes.localPickup.dailyData = orderTypeStats.localPickup.dailyData;
|
|
stats.orderTypes.heldItems.dailyData = orderTypeStats.heldItems.dailyData;
|
|
|
|
// Convert daily stats to array and sort
|
|
stats.dailyData = Array.from(dailyStats.values())
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.map(day => ({
|
|
date: day.date,
|
|
timestamp: day.timestamp,
|
|
revenue: Number(day.revenue || 0),
|
|
orders: Number(day.orders || 0),
|
|
itemCount: Number(day.itemCount || 0),
|
|
averageOrderValue: Number(day.averageOrderValue || 0),
|
|
averageItemsPerOrder: Number(day.averageItemsPerOrder || 0),
|
|
hourlyOrders: day.hourlyOrders || Array(24).fill(0),
|
|
refunds: {
|
|
total: Number(day.refunds?.total || 0),
|
|
count: Number(day.refunds?.count || 0),
|
|
reasons: day.refunds?.reasons || {}
|
|
},
|
|
canceledOrders: {
|
|
total: Number(day.canceledOrders?.total || 0),
|
|
count: Number(day.canceledOrders?.count || 0),
|
|
reasons: day.canceledOrders?.reasons || {}
|
|
},
|
|
orderTypes: {
|
|
preOrders: {
|
|
count: Number(day.orderTypes?.preOrders?.count || 0),
|
|
value: Number(day.orderTypes?.preOrders?.value || 0),
|
|
percentage: Number(day.orderTypes?.preOrders?.percentage || 0),
|
|
averageValue: Number(day.orderTypes?.preOrders?.averageValue || 0)
|
|
},
|
|
localPickup: {
|
|
count: Number(day.orderTypes?.localPickup?.count || 0),
|
|
value: Number(day.orderTypes?.localPickup?.value || 0),
|
|
percentage: Number(day.orderTypes?.localPickup?.percentage || 0),
|
|
averageValue: Number(day.orderTypes?.localPickup?.averageValue || 0)
|
|
},
|
|
heldItems: {
|
|
count: Number(day.orderTypes?.heldItems?.count || 0),
|
|
value: Number(day.orderTypes?.heldItems?.value || 0),
|
|
percentage: Number(day.orderTypes?.heldItems?.percentage || 0),
|
|
averageValue: Number(day.orderTypes?.heldItems?.averageValue || 0)
|
|
},
|
|
digital: {
|
|
count: Number(day.orderTypes?.digital?.count || 0),
|
|
value: Number(day.orderTypes?.digital?.value || 0),
|
|
percentage: Number(day.orderTypes?.digital?.percentage || 0),
|
|
averageValue: Number(day.orderTypes?.digital?.averageValue || 0)
|
|
},
|
|
giftCard: {
|
|
count: Number(day.orderTypes?.giftCard?.count || 0),
|
|
value: Number(day.orderTypes?.giftCard?.value || 0),
|
|
percentage: Number(day.orderTypes?.giftCard?.percentage || 0),
|
|
averageValue: Number(day.orderTypes?.giftCard?.averageValue || 0)
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Set totals
|
|
stats.brands.total = stats.brands.list.length;
|
|
stats.categories.total = stats.categories.list.length;
|
|
|
|
return stats;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error calculating period stats:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
_getEmptyStats() {
|
|
return {
|
|
orderCount: 0,
|
|
revenue: 0,
|
|
averageOrderValue: 0,
|
|
itemCount: 0,
|
|
prevPeriodRevenue: 0,
|
|
projectedRevenue: 0,
|
|
periodProgress: 0,
|
|
dailyData: [],
|
|
products: {
|
|
list: [],
|
|
total: 0,
|
|
status: {
|
|
backordered: 0,
|
|
inStock: 0,
|
|
outOfStock: 0,
|
|
preorder: 0
|
|
}
|
|
},
|
|
shipping: {
|
|
shippedCount: 0,
|
|
locations: {
|
|
total: 0,
|
|
byState: [],
|
|
byCountry: []
|
|
},
|
|
methods: {},
|
|
methodStats: [],
|
|
methodPercentages: {}
|
|
},
|
|
refunds: {
|
|
total: 0,
|
|
count: 0,
|
|
reasons: {},
|
|
items: [],
|
|
averageAmount: 0
|
|
},
|
|
canceledOrders: {
|
|
total: 0,
|
|
count: 0,
|
|
reasons: {},
|
|
items: [],
|
|
averageAmount: 0
|
|
},
|
|
orderTypes: {
|
|
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
|
|
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
|
|
},
|
|
brands: {
|
|
total: 0,
|
|
list: [],
|
|
topBrands: [],
|
|
totalRevenue: 0,
|
|
averageOrderValue: 0
|
|
},
|
|
categories: {
|
|
total: 0,
|
|
list: [],
|
|
topCategories: [],
|
|
totalRevenue: 0,
|
|
averageOrderValue: 0
|
|
},
|
|
hourlyOrders: Array(24).fill(0),
|
|
peakOrderHour: null,
|
|
bestRevenueDay: null,
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
async getMultiMetricEvents(params = {}) {
|
|
try {
|
|
const { timeRange, startDate, endDate, metricIds } = params;
|
|
const metrics = metricIds || Object.values(METRIC_IDS);
|
|
|
|
// Try to get from cache first
|
|
const cacheKey = this.redisService._getCacheKey('events', params);
|
|
let cachedData = null;
|
|
try {
|
|
cachedData = await this.redisService.get(`${cacheKey}:feed`);
|
|
if (cachedData) {
|
|
return cachedData;
|
|
}
|
|
} catch (cacheError) {
|
|
console.warn('[EventsService] Cache error:', cacheError);
|
|
}
|
|
|
|
// Fetch events for all specified metrics
|
|
const eventPromises = metrics.map(metricId =>
|
|
this.getEvents({
|
|
startDate,
|
|
endDate,
|
|
timeRange,
|
|
metricId,
|
|
sort: '-datetime'
|
|
})
|
|
);
|
|
|
|
const results = await Promise.all(eventPromises);
|
|
|
|
// Transform and flatten the events into a single array
|
|
const allEvents = [];
|
|
metrics.forEach((metric, index) => {
|
|
const response = results[index];
|
|
const events = response?.data || [];
|
|
if (Array.isArray(events)) {
|
|
const transformedEvents = events.map(event => ({
|
|
...event,
|
|
metric_id: metric,
|
|
datetime: event.attributes?.datetime || event.datetime,
|
|
event_properties: {
|
|
...event.event_properties,
|
|
datetime: event.attributes?.datetime || event.datetime,
|
|
},
|
|
}));
|
|
allEvents.push(...transformedEvents);
|
|
}
|
|
});
|
|
|
|
// Sort all events by datetime in descending order
|
|
allEvents.sort((a, b) => {
|
|
const dateA = new Date(a.datetime);
|
|
const dateB = new Date(b.datetime);
|
|
return dateB - dateA;
|
|
});
|
|
|
|
const result = { data: allEvents };
|
|
|
|
// Cache the result
|
|
try {
|
|
const ttl = this.redisService._getTTL(params.timeRange);
|
|
await this.redisService.set(`${cacheKey}:feed`, result, ttl);
|
|
} catch (cacheError) {
|
|
console.warn('[EventsService] Cache set error:', cacheError);
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error in batch metrics:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
_transformEvents(events) {
|
|
if (!Array.isArray(events)) {
|
|
console.warn('[EventsService] Events is not an array:', events);
|
|
return [];
|
|
}
|
|
|
|
return events.map(event => {
|
|
try {
|
|
// Extract metric ID from all possible locations
|
|
const metricId = event.relationships?.metric?.data?.id ||
|
|
event.attributes?.metric?.id ||
|
|
event.attributes?.metric_id;
|
|
|
|
// Extract properties from all possible locations
|
|
const rawProps = {
|
|
...(event.attributes?.event_properties || {}),
|
|
...(event.attributes?.properties || {}),
|
|
...(event.attributes?.profile || {}),
|
|
value: event.attributes?.value,
|
|
datetime: event.attributes?.datetime
|
|
};
|
|
|
|
// Normalize shipping data
|
|
const shippingData = {
|
|
name: rawProps.ShippingName || rawProps.shipping_name || rawProps.shipping?.name,
|
|
street1: rawProps.ShippingStreet1 || rawProps.shipping_street1 || rawProps.shipping?.street1,
|
|
street2: rawProps.ShippingStreet2 || rawProps.shipping_street2 || rawProps.shipping?.street2,
|
|
city: rawProps.ShippingCity || rawProps.shipping_city || rawProps.shipping?.city,
|
|
state: rawProps.ShippingState || rawProps.shipping_state || rawProps.shipping?.state,
|
|
zip: rawProps.ShippingZip || rawProps.shipping_zip || rawProps.shipping?.zip,
|
|
country: rawProps.ShippingCountry || rawProps.shipping_country || rawProps.shipping?.country,
|
|
method: rawProps.ShipMethod || rawProps.shipping_method || rawProps.shipping?.method,
|
|
tracking: rawProps.TrackingNumber || rawProps.tracking_number
|
|
};
|
|
|
|
// Normalize payment data
|
|
const paymentData = {
|
|
method: rawProps.PaymentMethod || rawProps.payment_method || rawProps.payment?.method,
|
|
name: rawProps.PaymentName || rawProps.payment_name || rawProps.payment?.name,
|
|
amount: Number(rawProps.PaymentAmount || rawProps.payment_amount || rawProps.payment?.amount || 0)
|
|
};
|
|
|
|
// Normalize order flags
|
|
const orderFlags = {
|
|
type: rawProps.OrderType || rawProps.order_type || 'standard',
|
|
hasPreorder: Boolean(rawProps.HasPreorder || rawProps.has_preorder || rawProps.preorder),
|
|
localPickup: Boolean(rawProps.LocalPickup || rawProps.local_pickup || rawProps.pickup),
|
|
isOnHold: Boolean(rawProps.IsOnHold || rawProps.is_on_hold || rawProps.on_hold),
|
|
hasDigiItem: Boolean(rawProps.HasDigiItem || rawProps.has_digital_item || rawProps.digital_item),
|
|
hasNotions: Boolean(rawProps.HasNotions || rawProps.has_notions || rawProps.notions),
|
|
hasDigitalGC: Boolean(rawProps.HasDigitalGC || rawProps.has_digital_gc || rawProps.gift_card),
|
|
stillOwes: Boolean(rawProps.StillOwes || rawProps.still_owes || rawProps.balance_due)
|
|
};
|
|
|
|
// Normalize refund/cancel data
|
|
const refundData = {
|
|
reason: rawProps.CancelReason || rawProps.cancel_reason || rawProps.reason,
|
|
message: rawProps.CancelMessage || rawProps.cancel_message || rawProps.message,
|
|
orderMessage: rawProps.OrderMessage || rawProps.order_message || rawProps.note
|
|
};
|
|
|
|
// Transform items
|
|
const items = this._transformItems(rawProps.Items || rawProps.items || rawProps.line_items || []);
|
|
|
|
// Calculate totals
|
|
const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.total_amount || rawProps.value || 0);
|
|
const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0);
|
|
|
|
const transformed = {
|
|
id: event.id,
|
|
type: event.type,
|
|
metric_id: metricId,
|
|
attributes: {
|
|
...event.attributes,
|
|
datetime: event.attributes?.datetime,
|
|
value: event.attributes?.value,
|
|
metric: {
|
|
...event.attributes?.metric,
|
|
id: metricId
|
|
}
|
|
},
|
|
relationships: event.relationships,
|
|
event_properties: {
|
|
// Basic properties
|
|
EmailAddress: rawProps.EmailAddress || rawProps.email,
|
|
FirstName: rawProps.FirstName || rawProps.first_name,
|
|
LastName: rawProps.LastName || rawProps.last_name,
|
|
OrderId: rawProps.OrderId || rawProps.FromOrder || rawProps.order_id,
|
|
TotalAmount: totalAmount,
|
|
ItemCount: itemCount,
|
|
Items: items,
|
|
|
|
// Shipping information
|
|
...shippingData,
|
|
|
|
// Payment information
|
|
...paymentData,
|
|
|
|
// Order flags
|
|
...orderFlags,
|
|
|
|
// Refund/cancel information
|
|
...refundData,
|
|
|
|
// Original properties (for backward compatibility)
|
|
...rawProps
|
|
}
|
|
};
|
|
|
|
return transformed;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error transforming event:', error, event);
|
|
// Return a minimal valid event structure
|
|
return {
|
|
id: event.id || 'unknown',
|
|
type: event.type || 'unknown',
|
|
metric_id: event.relationships?.metric?.data?.id || 'unknown',
|
|
attributes: event.attributes || {},
|
|
event_properties: {}
|
|
};
|
|
}
|
|
}).filter(Boolean); // Remove any null/undefined events
|
|
}
|
|
|
|
_transformItems(items) {
|
|
if (!Array.isArray(items)) {
|
|
console.warn('[EventsService] Items is not an array:', items);
|
|
return [];
|
|
}
|
|
|
|
return items.map(item => {
|
|
try {
|
|
const quantity = Number(item.Quantity || item.QuantityOrdered || item.quantity || item.quantity_ordered || 1);
|
|
const price = Number(item.ItemPrice || item.item_price || item.price || 0);
|
|
const rowTotal = Number(item.RowTotal || item.row_total || (price * quantity) || 0);
|
|
|
|
const transformed = {
|
|
// Basic item information
|
|
ProductID: item.ProductID || item.product_id || item.id,
|
|
ProductName: item.ProductName || item.product_name || item.name,
|
|
SKU: item.SKU || item.sku,
|
|
Brand: item.Brand || item.brand,
|
|
Categories: Array.isArray(item.Categories) ? item.Categories :
|
|
Array.isArray(item.categories) ? item.categories : [],
|
|
|
|
// Pricing
|
|
ItemPrice: price,
|
|
RowTotal: rowTotal,
|
|
|
|
// Quantities
|
|
Quantity: quantity,
|
|
QuantityOrdered: quantity,
|
|
QuantitySent: Number(item.QuantitySent || item.quantity_sent || 0),
|
|
QuantityBackordered: Number(item.QuantityBackordered || item.quantity_backordered || 0),
|
|
|
|
// Status and images
|
|
ItemStatus: item.ItemStatus || item.item_status || item.status || 'In Stock',
|
|
ImgThumb: item.ImgThumb || item.img_thumb || item.thumbnail,
|
|
|
|
// Additional properties
|
|
IsPreorder: Boolean(item.IsPreorder || item.is_preorder || item.preorder),
|
|
IsDigital: Boolean(item.IsDigital || item.is_digital || item.digital),
|
|
IsGiftCard: Boolean(item.IsGiftCard || item.is_gift_card || item.gift_card),
|
|
|
|
// Original properties (for backward compatibility)
|
|
...item
|
|
};
|
|
|
|
return transformed;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error transforming item:', error, item);
|
|
// Return a minimal valid item structure
|
|
return {
|
|
ProductID: item.ProductID || item.product_id || 'unknown',
|
|
ProductName: item.ProductName || item.product_name || 'Unknown Product',
|
|
Quantity: 1,
|
|
ItemPrice: 0,
|
|
RowTotal: 0
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
_formatHour(hour) {
|
|
if (hour === 0) return "12:00 AM";
|
|
if (hour === 12) return "12:00 PM";
|
|
if (hour > 12) return `${hour - 12}:00 PM`;
|
|
return `${hour}:00 AM`;
|
|
}
|
|
|
|
async calculateDetailedStats(params = {}) {
|
|
try {
|
|
const { metric, daily = false } = params;
|
|
console.log('[EventsService] Request params:', params);
|
|
|
|
// Get period dates using the same logic as calculatePeriodStats
|
|
let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd;
|
|
if (params.startDate && params.endDate) {
|
|
periodStart = this.timeManager.toDateTime(params.startDate);
|
|
periodEnd = this.timeManager.toDateTime(params.endDate);
|
|
const duration = periodEnd.diff(periodStart);
|
|
prevPeriodStart = periodStart.minus(duration);
|
|
prevPeriodEnd = periodStart.minus({ milliseconds: 1 });
|
|
} else if (params.timeRange) {
|
|
const range = this.timeManager.getDateRange(params.timeRange);
|
|
periodStart = range.start;
|
|
periodEnd = range.end;
|
|
const duration = periodEnd.diff(periodStart);
|
|
prevPeriodStart = periodStart.minus(duration);
|
|
prevPeriodEnd = periodStart.minus({ milliseconds: 1 });
|
|
}
|
|
|
|
// Load both current and previous period data
|
|
const [currentResponse, prevResponse] = await Promise.all([
|
|
this.getEvents({
|
|
...params,
|
|
startDate: periodStart.toISO(),
|
|
endDate: periodEnd.toISO(),
|
|
metricId: METRIC_IDS.PLACED_ORDER
|
|
}),
|
|
this.getEvents({
|
|
..._.omit(params, ['timeRange', 'startDate', 'endDate']),
|
|
startDate: prevPeriodStart.toISO(),
|
|
endDate: prevPeriodEnd.toISO(),
|
|
metricId: METRIC_IDS.PLACED_ORDER
|
|
})
|
|
]);
|
|
|
|
// Transform events
|
|
const currentEvents = this._transformEvents(currentResponse.data || []);
|
|
const prevEvents = this._transformEvents(prevResponse.data || []);
|
|
|
|
// Filter events based on metric type
|
|
const filterEvents = (events) => {
|
|
switch (metric) {
|
|
case 'pre_orders':
|
|
return events.filter(event =>
|
|
Boolean(event.event_properties?.HasPreorder ||
|
|
event.event_properties?.has_preorder ||
|
|
event.event_properties?.preorder)
|
|
);
|
|
case 'local_pickup':
|
|
return events.filter(event =>
|
|
Boolean(event.event_properties?.LocalPickup ||
|
|
event.event_properties?.local_pickup ||
|
|
event.event_properties?.pickup)
|
|
);
|
|
case 'on_hold':
|
|
return events.filter(event =>
|
|
Boolean(event.event_properties?.IsOnHold ||
|
|
event.event_properties?.is_on_hold ||
|
|
event.event_properties?.on_hold)
|
|
);
|
|
default:
|
|
return events;
|
|
}
|
|
};
|
|
|
|
const filteredCurrentEvents = filterEvents(currentEvents);
|
|
const filteredPrevEvents = filterEvents(prevEvents);
|
|
|
|
// Initialize daily stats map with all dates in range
|
|
const dailyStats = new Map();
|
|
let currentDate = periodStart;
|
|
while (currentDate <= periodEnd) {
|
|
const dateKey = currentDate.toFormat('yyyy-MM-dd');
|
|
dailyStats.set(dateKey, {
|
|
date: currentDate.toISO(),
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
averageOrderValue: 0,
|
|
averageItemsPerOrder: 0,
|
|
count: 0,
|
|
value: 0,
|
|
percentage: 0,
|
|
totalOrders: 0,
|
|
prevRevenue: 0,
|
|
prevOrders: 0,
|
|
prevItemCount: 0,
|
|
prevAvgOrderValue: 0,
|
|
orderValueRange: {
|
|
largest: 0,
|
|
smallest: 0,
|
|
largestOrderId: null,
|
|
smallestOrderId: null,
|
|
distribution: {
|
|
under25: { count: 0, total: 0 },
|
|
under50: { count: 0, total: 0 },
|
|
under100: { count: 0, total: 0 },
|
|
under200: { count: 0, total: 0 },
|
|
over200: { count: 0, total: 0 }
|
|
}
|
|
}
|
|
});
|
|
currentDate = currentDate.plus({ days: 1 });
|
|
}
|
|
|
|
// Get total orders for the period (needed for percentages)
|
|
const totalOrders = currentEvents.length;
|
|
|
|
// Process current period events
|
|
for (const event of filteredCurrentEvents) {
|
|
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
|
|
if (!datetime) continue;
|
|
|
|
const dateKey = datetime.toFormat('yyyy-MM-dd');
|
|
if (!dailyStats.has(dateKey)) continue;
|
|
|
|
const dayStats = dailyStats.get(dateKey);
|
|
const props = event.event_properties || {};
|
|
const totalAmount = Number(props.TotalAmount || 0);
|
|
const items = props.Items || [];
|
|
const orderId = props.OrderId;
|
|
|
|
dayStats.revenue += totalAmount;
|
|
dayStats.orders++;
|
|
dayStats.itemCount += items.length;
|
|
dayStats.value += totalAmount;
|
|
dayStats.count++;
|
|
dayStats.totalOrders = totalOrders;
|
|
dayStats.percentage = (dayStats.count / totalOrders) * 100;
|
|
dayStats.averageOrderValue = dayStats.revenue / dayStats.orders;
|
|
dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders;
|
|
|
|
// Track daily order value range
|
|
if (totalAmount > dayStats.orderValueRange.largest) {
|
|
dayStats.orderValueRange.largest = totalAmount;
|
|
dayStats.orderValueRange.largestOrderId = orderId;
|
|
}
|
|
if (totalAmount > 0) {
|
|
if (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest) {
|
|
dayStats.orderValueRange.smallest = totalAmount;
|
|
dayStats.orderValueRange.smallestOrderId = orderId;
|
|
}
|
|
|
|
// Track order value distribution with more granular ranges
|
|
const ranges = [
|
|
{ min: 0, max: 25 },
|
|
{ min: 25, max: 50 },
|
|
{ min: 50, max: 75 },
|
|
{ min: 75, max: 100 },
|
|
{ min: 100, max: 150 },
|
|
{ min: 150, max: 200 },
|
|
{ min: 200, max: 300 },
|
|
{ min: 300, max: 500 },
|
|
{ min: 500, max: Infinity }
|
|
];
|
|
|
|
// Initialize distribution if not exists
|
|
if (!dayStats.orderValueDistribution) {
|
|
dayStats.orderValueDistribution = ranges.map(range => ({
|
|
min: range.min,
|
|
max: range.max === Infinity ? 'Infinity' : range.max,
|
|
count: 0,
|
|
total: 0
|
|
}));
|
|
}
|
|
|
|
// Find the appropriate range and update counts
|
|
const rangeIndex = ranges.findIndex(range =>
|
|
totalAmount >= range.min && totalAmount < range.max
|
|
);
|
|
|
|
if (rangeIndex !== -1) {
|
|
dayStats.orderValueDistribution[rangeIndex].count++;
|
|
dayStats.orderValueDistribution[rangeIndex].total += totalAmount;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process previous period events
|
|
const prevDailyStats = new Map();
|
|
let prevDate = prevPeriodStart;
|
|
while (prevDate <= prevPeriodEnd) {
|
|
const dateKey = prevDate.toFormat('yyyy-MM-dd');
|
|
prevDailyStats.set(dateKey, {
|
|
date: prevDate.toISO(),
|
|
timestamp: dateKey,
|
|
revenue: 0,
|
|
orders: 0,
|
|
itemCount: 0,
|
|
value: 0,
|
|
count: 0
|
|
});
|
|
prevDate = prevDate.plus({ days: 1 });
|
|
}
|
|
|
|
// Get total orders for previous period
|
|
const prevTotalOrders = prevEvents.length;
|
|
|
|
// Process previous period events
|
|
for (const event of filteredPrevEvents) {
|
|
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
|
|
if (!datetime) continue;
|
|
|
|
const dateKey = datetime.toFormat('yyyy-MM-dd');
|
|
if (!prevDailyStats.has(dateKey)) continue;
|
|
|
|
const dayStats = prevDailyStats.get(dateKey);
|
|
const props = event.event_properties || {};
|
|
const totalAmount = Number(props.TotalAmount || 0);
|
|
const items = props.Items || [];
|
|
|
|
dayStats.revenue += totalAmount;
|
|
dayStats.orders++;
|
|
dayStats.itemCount += items.length;
|
|
dayStats.value += totalAmount;
|
|
dayStats.count++;
|
|
dayStats.percentage = (dayStats.count / prevTotalOrders) * 100;
|
|
}
|
|
|
|
// Map previous period data to current period days
|
|
const prevPeriodDays = Array.from(prevDailyStats.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
const currentPeriodDays = Array.from(dailyStats.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
// Map the data using array indices
|
|
for (let i = 0; i < currentPeriodDays.length && i < prevPeriodDays.length; i++) {
|
|
const currentDayStats = currentPeriodDays[i];
|
|
const prevDayStats = prevPeriodDays[i];
|
|
|
|
if (prevDayStats && currentDayStats) {
|
|
const dayStats = dailyStats.get(currentDayStats.timestamp);
|
|
if (dayStats) {
|
|
dayStats.prevRevenue = prevDayStats.revenue;
|
|
dayStats.prevOrders = prevDayStats.orders;
|
|
dayStats.prevItemCount = prevDayStats.itemCount;
|
|
dayStats.prevValue = prevDayStats.value;
|
|
dayStats.prevCount = prevDayStats.count;
|
|
dayStats.prevPercentage = prevDayStats.percentage;
|
|
dayStats.prevAvgOrderValue = prevDayStats.orders > 0 ? prevDayStats.revenue / prevDayStats.orders : 0;
|
|
dailyStats.set(currentDayStats.timestamp, dayStats);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to array and sort by date
|
|
const stats = Array.from(dailyStats.values())
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.map(day => ({
|
|
...day,
|
|
revenue: Number(day.revenue || 0),
|
|
orders: Number(day.orders || 0),
|
|
itemCount: Number(day.itemCount || 0),
|
|
averageOrderValue: Number(day.averageOrderValue || 0),
|
|
averageItemsPerOrder: Number(day.averageItemsPerOrder || 0),
|
|
count: Number(day.count || 0),
|
|
value: Number(day.value || 0),
|
|
percentage: Number(day.percentage || 0),
|
|
totalOrders: Number(day.totalOrders || 0),
|
|
prevRevenue: Number(day.prevRevenue || 0),
|
|
prevOrders: Number(day.prevOrders || 0),
|
|
prevItemCount: Number(day.prevItemCount || 0),
|
|
prevValue: Number(day.prevValue || 0),
|
|
prevCount: Number(day.prevCount || 0),
|
|
prevPercentage: Number(day.prevPercentage || 0),
|
|
prevAvgOrderValue: Number(day.prevAvgOrderValue || 0),
|
|
orderValueRange: {
|
|
largest: Number(day.orderValueRange?.largest || 0),
|
|
smallest: Number(day.orderValueRange?.smallest || 0),
|
|
largestOrderId: day.orderValueRange?.largestOrderId || null,
|
|
smallestOrderId: day.orderValueRange?.smallestOrderId || null
|
|
},
|
|
orderValueDistribution: day.orderValueDistribution || []
|
|
}));
|
|
|
|
return stats;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error calculating detailed stats:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
_getColorForBrand(brandName) {
|
|
// Generate a consistent color based on the brand name
|
|
let hash = 0;
|
|
for (let i = 0; i < brandName.length; i++) {
|
|
hash = brandName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
// Use HSL to ensure colors are visually distinct and pleasing
|
|
const hue = Math.abs(hash % 360);
|
|
return `hsl(${hue}, 70%, 50%)`;
|
|
}
|
|
|
|
_getColorForCategory(categoryName) {
|
|
// Generate a consistent color based on the category name
|
|
let hash = 0;
|
|
for (let i = 0; i < categoryName.length; i++) {
|
|
hash = categoryName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
// Use HSL with different saturation/lightness than brands
|
|
const hue = Math.abs(hash % 360);
|
|
return `hsl(${hue}, 60%, 60%)`;
|
|
}
|
|
|
|
async getBatchMetrics(params = {}) {
|
|
try {
|
|
const { timeRange, startDate, endDate, metrics = [] } = params;
|
|
|
|
// Create a map of all metric requests
|
|
const metricPromises = metrics.map(metric => {
|
|
switch(metric) {
|
|
case 'orders':
|
|
return this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.PLACED_ORDER
|
|
});
|
|
case 'revenue':
|
|
return this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.PLACED_ORDER,
|
|
property: 'TotalAmount'
|
|
});
|
|
case 'refunds':
|
|
return this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.PAYMENT_REFUNDED
|
|
});
|
|
case 'cancellations':
|
|
return this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.CANCELED_ORDER
|
|
});
|
|
case 'shipping':
|
|
return this.getEvents({
|
|
...params,
|
|
metricId: METRIC_IDS.SHIPPED_ORDER
|
|
});
|
|
default:
|
|
return Promise.resolve(null);
|
|
}
|
|
});
|
|
|
|
// Execute all promises in parallel
|
|
const results = await Promise.all(metricPromises);
|
|
|
|
// Transform results into a keyed object
|
|
const batchResults = {};
|
|
metrics.forEach((metric, index) => {
|
|
batchResults[metric] = results[index];
|
|
});
|
|
|
|
return batchResults;
|
|
} catch (error) {
|
|
console.error('[EventsService] Error in batch metrics:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|