package processor import ( "context" "fmt" "time" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) type AnalyticsProcessor interface { GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) } type AnalyticsProcessorImpl struct { analyticsRepo repository.AnalyticsRepository expenseRepo ExpenseRepository } func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl { return &AnalyticsProcessorImpl{ analyticsRepo: analyticsRepo, expenseRepo: expenseRepo, } } func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) { if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } analyticsData, err := p.analyticsRepo.GetPaymentMethodAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get payment method analytics: %w", err) } var totalAmount float64 var totalOrders int64 var totalPayments int64 for _, data := range analyticsData { totalAmount += data.TotalAmount totalOrders += data.OrderCount totalPayments += data.PaymentCount } var averageOrderValue float64 if totalOrders > 0 { averageOrderValue = totalAmount / float64(totalOrders) } // Calculate percentages var resultData []models.PaymentMethodAnalyticsData for _, data := range analyticsData { var percentage float64 if totalAmount > 0 { percentage = (data.TotalAmount / totalAmount) * 100 } resultData = append(resultData, models.PaymentMethodAnalyticsData{ PaymentMethodID: data.PaymentMethodID, PaymentMethodName: data.PaymentMethodName, PaymentMethodType: data.PaymentMethodType, TotalAmount: data.TotalAmount, OrderCount: data.OrderCount, PaymentCount: data.PaymentCount, Percentage: percentage, }) } summary := models.PaymentMethodSummary{ TotalAmount: totalAmount, TotalOrders: totalOrders, TotalPayments: totalPayments, AverageOrderValue: averageOrderValue, } return &models.PaymentMethodAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, Summary: summary, Data: resultData, }, nil } func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) { // Validate date range if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } // Validate groupBy if req.GroupBy == "" { req.GroupBy = "day" } // Get analytics data from repository analyticsData, err := p.analyticsRepo.GetSalesAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get sales analytics: %w", err) } // Calculate summary var totalSales float64 var totalOrders int64 var totalItems int64 var totalTax float64 var totalDiscount float64 var netSales float64 for _, data := range analyticsData { totalSales += data.Sales totalOrders += data.Orders totalItems += data.Items totalTax += data.Tax totalDiscount += data.Discount netSales += data.NetSales } var averageOrderValue float64 if totalOrders > 0 { averageOrderValue = totalSales / float64(totalOrders) } // Transform data var resultData []models.SalesAnalyticsData for _, data := range analyticsData { resultData = append(resultData, models.SalesAnalyticsData{ Date: data.Date, Sales: data.Sales, Orders: data.Orders, Items: data.Items, Tax: data.Tax, Discount: data.Discount, NetSales: data.NetSales, }) } summary := models.SalesSummary{ TotalSales: totalSales, TotalOrders: totalOrders, TotalItems: totalItems, AverageOrderValue: averageOrderValue, TotalTax: totalTax, TotalDiscount: totalDiscount, NetSales: netSales, } return &models.SalesAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, Summary: summary, Data: resultData, }, nil } func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) { if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } if req.GroupBy == "" { req.GroupBy = "day" } result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) } data := make([]models.PurchasingAnalyticsData, len(result.Data)) for i, item := range result.Data { data[i] = models.PurchasingAnalyticsData{ Date: item.Date, Purchases: item.Purchases, PurchaseOrders: item.PurchaseOrders, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, } } ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData)) for i, item := range result.IngredientData { ingredientData[i] = models.PurchasingIngredientData{ IngredientID: item.IngredientID, IngredientName: item.IngredientName, Quantity: item.Quantity, TotalCost: item.TotalCost, AverageUnitCost: item.AverageUnitCost, PurchaseOrderCount: item.PurchaseOrderCount, } } vendorData := make([]models.PurchasingVendorData, len(result.VendorData)) for i, item := range result.VendorData { vendorData[i] = models.PurchasingVendorData{ VendorID: item.VendorID, VendorName: item.VendorName, TotalCost: item.TotalCost, PurchaseOrderCount: item.PurchaseOrderCount, IngredientCount: item.IngredientCount, Quantity: item.Quantity, } } return &models.PurchasingAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: result.OutletName, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, Summary: models.PurchasingSummary{ TotalPurchases: result.Summary.TotalPurchases, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, TotalQuantity: result.Summary.TotalQuantity, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, }, nil } func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) { // Validate date range if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } // Set default limit if req.Limit <= 0 { req.Limit = 1000 } // Get analytics data from repository analyticsData, err := p.analyticsRepo.GetProductAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.Limit) if err != nil { return nil, fmt.Errorf("failed to get product analytics: %w", err) } // Transform data var resultData []models.ProductAnalyticsData for _, data := range analyticsData { resultData = append(resultData, models.ProductAnalyticsData{ ProductID: data.ProductID, ProductName: data.ProductName, ProductSku: data.ProductSku, CategoryID: data.CategoryID, CategoryName: data.CategoryName, CategoryOrder: data.CategoryOrder, QuantitySold: data.QuantitySold, Revenue: data.Revenue, AveragePrice: data.AveragePrice, OrderCount: data.OrderCount, StandardHppPerUnit: data.StandardHppPerUnit, StandardHppTotal: data.StandardHppTotal, FifoHppPerUnit: data.FifoHppPerUnit, FifoHppTotal: data.FifoHppTotal, MovingAverageHppPerUnit: data.MovingAverageHppPerUnit, MovingAverageHppTotal: data.MovingAverageHppTotal, }) } return &models.ProductAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, Data: resultData, }, nil } func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) { // Validate date range if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } // Get analytics data from repository analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get product analytics per category: %w", err) } // Transform data var resultData []models.ProductAnalyticsPerCategoryData for _, data := range analyticsData { resultData = append(resultData, models.ProductAnalyticsPerCategoryData{ CategoryID: data.CategoryID, CategoryName: data.CategoryName, TotalRevenue: data.TotalRevenue, TotalQuantity: data.TotalQuantity, ProductCount: data.ProductCount, OrderCount: data.OrderCount, TotalStandardHpp: data.TotalStandardHpp, TotalFifoHpp: data.TotalFifoHpp, TotalMovingAverageHpp: data.TotalMovingAverageHpp, }) } return &models.ProductAnalyticsPerCategoryResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, Data: resultData, }, nil } func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) { // Validate date range if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } // Get dashboard overview overview, err := p.analyticsRepo.GetDashboardOverview(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get dashboard overview: %w", err) } // Get top products (limit to 5 for dashboard) productReq := &models.ProductAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, Limit: 5, } topProducts, err := p.GetProductAnalytics(ctx, productReq) if err != nil { return nil, fmt.Errorf("failed to get top products: %w", err) } // Get payment methods paymentReq := &models.PaymentMethodAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: "day", } paymentMethods, err := p.GetPaymentMethodAnalytics(ctx, paymentReq) if err != nil { return nil, fmt.Errorf("failed to get payment methods: %w", err) } // Get recent sales (last 7 days) recentDateFrom := time.Now().AddDate(0, 0, -7) salesReq := &models.SalesAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: recentDateFrom, DateTo: req.DateTo, GroupBy: "day", } recentSales, err := p.GetSalesAnalytics(ctx, salesReq) if err != nil { return nil, fmt.Errorf("failed to get recent sales: %w", err) } return &models.DashboardAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, Overview: models.DashboardOverview{ TotalSales: overview.TotalSales, TotalOrders: overview.TotalOrders, AverageOrderValue: overview.AverageOrderValue, TotalCustomers: overview.TotalCustomers, VoidedOrders: overview.VoidedOrders, RefundedOrders: overview.RefundedOrders, }, TopProducts: topProducts.Data, PaymentMethods: paymentMethods.Data, RecentSales: recentSales.Data, }, nil } func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { if req.DateFrom.IsZero() { return nil, fmt.Errorf("date_from is required") } if req.DateTo.IsZero() { return nil, fmt.Errorf("date_to is required") } if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } if req.GroupBy == "" { req.GroupBy = "day" } result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } data := make([]models.ProfitLossData, len(result.Data)) for i, item := range result.Data { data[i] = models.ProfitLossData{ Date: item.Date, Revenue: item.Revenue, Cost: item.Cost, GrossProfit: item.GrossProfit, GrossProfitMargin: item.GrossProfitMargin, Tax: item.Tax, Discount: item.Discount, NetProfit: item.NetProfit, NetProfitMargin: item.NetProfitMargin, Orders: item.Orders, } } productData := make([]models.ProductProfitData, len(result.ProductData)) for i, item := range result.ProductData { productData[i] = models.ProductProfitData{ ProductID: item.ProductID, ProductName: item.ProductName, CategoryID: item.CategoryID, CategoryName: item.CategoryName, QuantitySold: item.QuantitySold, Revenue: item.Revenue, Cost: item.Cost, GrossProfit: item.GrossProfit, GrossProfitMargin: item.GrossProfitMargin, AveragePrice: item.AveragePrice, AverageCost: item.AverageCost, ProfitPerUnit: item.ProfitPerUnit, } } type categoryAmount struct { Name string TodayAmt float64 MtdAmt float64 } categoryMap := make(map[string]*categoryAmount) var categoryOrder []string for _, cat := range result.TodayExpenseByCategory { name := cat.CategoryName if _, exists := categoryMap[name]; !exists { categoryMap[name] = &categoryAmount{Name: name} categoryOrder = append(categoryOrder, name) } categoryMap[name].TodayAmt = cat.Amount } for _, cat := range result.MtdExpenseByCategory { name := cat.CategoryName if _, exists := categoryMap[name]; !exists { categoryMap[name] = &categoryAmount{Name: name} categoryOrder = append(categoryOrder, name) } categoryMap[name].MtdAmt = cat.Amount } var todayTotalOps float64 var mtdTotalOps float64 for _, cat := range categoryMap { todayTotalOps += cat.TodayAmt mtdTotalOps += cat.MtdAmt } todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost todayNetProfit := todayGrossProfit - todayTotalOps mtdNetProfit := mtdGrossProfit - mtdTotalOps todayPct := func(nominal float64) float64 { if result.TodayRevenue == 0 { return 0 } return (nominal / result.TodayRevenue) * 100 } mtdPct := func(nominal float64) float64 { if result.MtdRevenue == 0 { return 0 } return (nominal / result.MtdRevenue) * 100 } opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1) for i, name := range categoryOrder { cat := categoryMap[name] opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ ID: fmt.Sprintf("by_%s", slugify(name)), Label: fmt.Sprintf("%d. %s", i+1, cat.Name), TodayNominal: cat.TodayAmt, TodayPct: todayPct(cat.TodayAmt), MtdNominal: cat.MtdAmt, MtdPct: mtdPct(cat.MtdAmt), }) } opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ ID: "total_biaya_ops", Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", len(categoryOrder)), IsBold: true, TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), }) mainSummary := []models.ProfitLossSummaryRow{ { ID: "total_omset", Label: "TOTAL OMSET", TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue), MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue), }, { ID: "hpp", Label: "HPP", TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost), MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost), }, { ID: "laba_kotor", Label: "Laba Kotor (1-2)", TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit), MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit), }, { ID: "biaya_ops", Label: "BIAYA OPS", TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), SubItems: opsSubItems, }, { ID: "laba_rugi", Label: "Laba/Rugi Bersih (3-4)", IsBold: true, TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit), MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit), }, } opsItems := make([]models.OperationalExpenseItem, len(result.OperationalExpenseItems)) var opsTotal float64 for i, item := range result.OperationalExpenseItems { opsItems[i] = models.OperationalExpenseItem{ Item: item.Item, Nominal: item.Amount, } opsTotal += item.Amount } return &models.ProfitLossAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, Summary: models.ProfitLossSummary{ TotalRevenue: result.Summary.TotalRevenue, TotalCost: result.Summary.TotalCost, GrossProfit: result.Summary.GrossProfit, GrossProfitMargin: result.Summary.GrossProfitMargin, TotalTax: result.Summary.TotalTax, TotalDiscount: result.Summary.TotalDiscount, NetProfit: result.Summary.NetProfit, NetProfitMargin: result.Summary.NetProfitMargin, TotalOrders: result.Summary.TotalOrders, AverageProfit: result.Summary.AverageProfit, ProfitabilityRatio: result.Summary.ProfitabilityRatio, }, Data: data, ProductData: productData, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: opsTotal, }, nil } func slugify(s string) string { result := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z': result = append(result, c) case c >= 'A' && c <= 'Z': result = append(result, c+32) case c >= '0' && c <= '9': result = append(result, c) default: if len(result) == 0 || result[len(result)-1] != '_' { result = append(result, '_') } } } return string(result) }