import { Order } from '../store/slices/orderSlice'; import { Product } from '../store/slices/productSlice'; import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations'; export interface ProfitMetrics { totalRevenue: number; totalPrintingCosts: number; totalProfit: number; averageMargin: number; orderCount: number; profitableOrders: number; averageOrderValue: number; averageCost: number; } export interface ProductProfitability { name: string; revenue: number; cost: number; profit: number; margin: number; orders: number; quantity: number; averageOrderValue: number; profitPerUnit: number; } export interface MonthlyProfit { month: string; revenue: number; costs: number; profit: number; margin: number; orderCount: number; } export interface OrderProfitAnalysis { orderId: string; orderNumber: string; date: string; customerName: string; revenue: number; printingCosts: number; profit: number; margin: number; items: OrderItemAnalysis[]; profitCategory: 'excellent' | 'good' | 'average' | 'poor' | 'loss'; } export interface OrderItemAnalysis { title: string; quantity: number; price: number; printingCost: number; itemRevenue: number; itemCost: number; itemProfit: number; itemMargin: number; } export interface DateRangeOption { value: string; label: string; type: 'preset' | 'month' | 'quarter' | 'year' | 'custom'; } export class ProfitAnalysisService { /** * Calculate comprehensive profit metrics from orders */ static calculateProfitMetrics(orders: Order[], products: Product[]): ProfitMetrics { if (!orders || orders.length === 0) { return { totalRevenue: 0, totalPrintingCosts: 0, totalProfit: 0, averageMargin: 0, orderCount: 0, profitableOrders: 0, averageOrderValue: 0, averageCost: 0 }; } let totalRevenue = 0; let totalPrintingCosts = 0; let totalProfit = 0; let profitableOrderCount = 0; orders.forEach(order => { const revenue = order.total || 0; const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []); const printingCost = calculateOrderPrintingCost(enrichedItems); const profit = calculateOrderProfit(enrichedItems, revenue); totalRevenue += revenue; totalPrintingCosts += printingCost; totalProfit += profit; if (profit > 0) profitableOrderCount++; }); const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0; const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0; const averageCost = orders.length > 0 ? totalPrintingCosts / orders.length : 0; return { totalRevenue, totalPrintingCosts, totalProfit, averageMargin, orderCount: orders.length, profitableOrders: profitableOrderCount, averageOrderValue, averageCost }; } /** * Analyze profitability by product */ static analyzeProductProfitability(orders: Order[], products: Product[]): ProductProfitability[] { if (!orders || !products) return []; const productStats = new Map(); orders.forEach(order => { const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products); enrichedItems.forEach(item => { const revenue = (item.price || 0) * (item.quantity || 1); const cost = (item.printingCost || 0) * (item.quantity || 1); const profit = revenue - cost; if (!productStats.has(item.title)) { productStats.set(item.title, { name: item.title, revenue: 0, cost: 0, profit: 0, margin: 0, orders: 0, quantity: 0, averageOrderValue: 0, profitPerUnit: 0 }); } const stats = productStats.get(item.title)!; stats.revenue += revenue; stats.cost += cost; stats.profit += profit; stats.orders += 1; stats.quantity += item.quantity || 1; }); }); // Calculate derived metrics productStats.forEach(stats => { stats.margin = stats.revenue > 0 ? (stats.profit / stats.revenue) * 100 : 0; stats.averageOrderValue = stats.orders > 0 ? stats.revenue / stats.orders : 0; stats.profitPerUnit = stats.quantity > 0 ? stats.profit / stats.quantity : 0; }); return Array.from(productStats.values()) .sort((a, b) => b.profit - a.profit); } /** * Calculate monthly profit trends */ static calculateMonthlyTrends(orders: Order[], products: Product[]): MonthlyProfit[] { if (!orders || orders.length === 0) return []; const monthlyData = new Map(); orders.forEach(order => { const orderDate = new Date(order.dateOrdered); const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`; const monthName = orderDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' }); const revenue = order.total || 0; const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []); const costs = calculateOrderPrintingCost(enrichedItems); const profit = revenue - costs; if (!monthlyData.has(monthKey)) { monthlyData.set(monthKey, { month: monthName, revenue: 0, costs: 0, profit: 0, margin: 0, orderCount: 0 }); } const monthStats = monthlyData.get(monthKey)!; monthStats.revenue += revenue; monthStats.costs += costs; monthStats.profit += profit; monthStats.orderCount += 1; }); // Calculate margins and sort by date const results = Array.from(monthlyData.entries()) .map(([key, data]) => { data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0; return { key, ...data }; }) .sort((a, b) => a.key.localeCompare(b.key)) .map(({ key, ...data }) => data); return results; } /** * Filter orders by date range */ static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] { if (dateRange === 'all' || !orders) return orders; const now = new Date(); const filteredOrders = orders.filter(order => { const orderDate = new Date(order.dateOrdered); // Handle specific month format (e.g., "2025-07" for July 2025) if (dateRange.match(/^\d{4}-\d{2}$/)) { const [year, month] = dateRange.split('-'); return orderDate.getFullYear() === parseInt(year) && orderDate.getMonth() === parseInt(month) - 1; } // Handle quarter format (e.g., "2025-Q1") if (dateRange.match(/^\d{4}-Q[1-4]$/)) { const [year, quarter] = dateRange.split('-Q'); const targetYear = parseInt(year); const targetQuarter = parseInt(quarter); const orderYear = orderDate.getFullYear(); const orderQuarter = Math.floor(orderDate.getMonth() / 3) + 1; return orderYear === targetYear && orderQuarter === targetQuarter; } // Handle year format (e.g., "2025") if (dateRange.match(/^\d{4}$/)) { return orderDate.getFullYear() === parseInt(dateRange); } // Handle custom date range format (e.g., "2025-01-01_2025-12-31") if (dateRange.includes('_')) { const [startDate, endDate] = dateRange.split('_'); const start = new Date(startDate); const end = new Date(endDate); return orderDate >= start && orderDate <= end; } // Handle legacy preset ranges const timeDiff = now.getTime() - orderDate.getTime(); const daysDiff = timeDiff / (24 * 60 * 60 * 1000); switch (dateRange) { case 'week': return daysDiff >= 0 && daysDiff <= 7; case 'month': return daysDiff >= 0 && daysDiff <= 30; case 'quarter': return daysDiff >= 0 && daysDiff <= 90; case 'year': return daysDiff >= 0 && daysDiff <= 365; default: return true; } }); return filteredOrders; } /** * Generate available date range options based on order data */ static generateDateRangeOptions(orders: Order[]): DateRangeOption[] { if (!orders || orders.length === 0) { return [{ value: 'all', label: 'All Time', type: 'preset' }]; } const options: DateRangeOption[] = [ { value: 'all', label: 'All Time', type: 'preset' }, { value: 'week', label: 'Last 7 Days', type: 'preset' }, { value: 'month', label: 'Last 30 Days', type: 'preset' }, { value: 'quarter', label: 'Last 90 Days', type: 'preset' }, { value: 'year', label: 'Last 365 Days', type: 'preset' } ]; // Get unique months from orders const monthsSet = new Set(); const quartersSet = new Set(); const yearsSet = new Set(); orders.forEach(order => { const date = new Date(order.dateOrdered); const year = date.getFullYear(); const month = date.getMonth(); const quarter = Math.floor(month / 3) + 1; // Add specific months const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`; monthsSet.add(monthKey); // Add quarters const quarterKey = `${year}-Q${quarter}`; quartersSet.add(quarterKey); // Add years yearsSet.add(year.toString()); }); // Convert to options and sort const monthOptions: DateRangeOption[] = Array.from(monthsSet) .sort((a, b) => b.localeCompare(a)) // Newest first .slice(0, 24) // Limit to last 24 months .map(monthKey => { const [year, month] = monthKey.split('-'); const date = new Date(parseInt(year), parseInt(month) - 1); const label = date.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' }); return { value: monthKey, label, type: 'month' as const }; }); const quarterOptions: DateRangeOption[] = Array.from(quartersSet) .sort((a, b) => b.localeCompare(a)) // Newest first .slice(0, 8) // Limit to last 8 quarters .map(quarterKey => ({ value: quarterKey, label: quarterKey.replace('-', ' '), type: 'quarter' as const })); const yearOptions: DateRangeOption[] = Array.from(yearsSet) .sort((a, b) => parseInt(b) - parseInt(a)) // Newest first .slice(0, 5) // Limit to last 5 years .map(year => ({ value: year, label: year, type: 'year' as const })); // Combine all options with separators return [ ...options, ...monthOptions, ...quarterOptions, ...yearOptions ]; } /** * Get date range label for display */ static getDateRangeLabel(dateRange: string, orders: Order[]): string { const options = this.generateDateRangeOptions(orders); const option = options.find(opt => opt.value === dateRange); return option ? option.label : dateRange; } /** * Get top/bottom performing products */ static getTopPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] { return this.analyzeProductProfitability(orders, products).slice(0, count); } static getWorstPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] { return this.analyzeProductProfitability(orders, products) .sort((a, b) => a.profit - b.profit) .slice(0, count); } /** * Calculate profit margin categories */ static categorizeByMargin(products: ProductProfitability[]): { excellent: ProductProfitability[]; // >50% good: ProductProfitability[]; // 30-50% average: ProductProfitability[]; // 15-30% poor: ProductProfitability[]; // 0-15% loss: ProductProfitability[]; // <0% } { return { excellent: products.filter(p => p.margin > 50), good: products.filter(p => p.margin > 30 && p.margin <= 50), average: products.filter(p => p.margin > 15 && p.margin <= 30), poor: products.filter(p => p.margin > 0 && p.margin <= 15), loss: products.filter(p => p.margin <= 0) }; } /** * Analyze individual order profitability with item breakdown */ static analyzeOrderProfitability(orders: Order[], products: Product[]): OrderProfitAnalysis[] { if (!orders || !products) return []; return orders.map(order => { const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products); const revenue = order.total || 0; const printingCosts = calculateOrderPrintingCost(enrichedItems); const profit = calculateOrderProfit(enrichedItems, revenue); const margin = revenue > 0 ? (profit / revenue) * 100 : 0; // Analyze each item const itemAnalyses: OrderItemAnalysis[] = enrichedItems.map(item => { const itemRevenue = (item.price || 0) * (item.quantity || 1); const itemCost = (item.printingCost || 0) * (item.quantity || 1); const itemProfit = itemRevenue - itemCost; const itemMargin = itemRevenue > 0 ? (itemProfit / itemRevenue) * 100 : 0; return { title: item.title, quantity: item.quantity || 1, price: item.price || 0, printingCost: item.printingCost || 0, itemRevenue, itemCost, itemProfit, itemMargin }; }); // Categorize profit performance let profitCategory: OrderProfitAnalysis['profitCategory']; if (margin > 50) profitCategory = 'excellent'; else if (margin > 30) profitCategory = 'good'; else if (margin > 15) profitCategory = 'average'; else if (margin > 0) profitCategory = 'poor'; else profitCategory = 'loss'; return { orderId: order._id, orderNumber: order.orderNumber, date: order.dateOrdered, customerName: order.customer?.name || 'Unknown Customer', revenue, printingCosts, profit, margin, items: itemAnalyses, profitCategory }; }).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); } /** * Get orders by profit category */ static getOrdersByProfitCategory(orders: Order[], products: Product[]): { excellent: OrderProfitAnalysis[]; good: OrderProfitAnalysis[]; average: OrderProfitAnalysis[]; poor: OrderProfitAnalysis[]; loss: OrderProfitAnalysis[]; } { const analyses = this.analyzeOrderProfitability(orders, products); return { excellent: analyses.filter(a => a.profitCategory === 'excellent'), good: analyses.filter(a => a.profitCategory === 'good'), average: analyses.filter(a => a.profitCategory === 'average'), poor: analyses.filter(a => a.profitCategory === 'poor'), loss: analyses.filter(a => a.profitCategory === 'loss') }; } } export default ProfitAnalysisService;