package processor import ( "context" "fmt" "strings" "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" "github.com/google/uuid" ) 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) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) } type AnalyticsProcessorImpl struct { analyticsRepo repository.AnalyticsRepository expenseRepo ExpenseRepository } func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl { return &AnalyticsProcessorImpl{ analyticsRepo: analyticsRepo, expenseRepo: expenseRepo, } } // resolveOutletName fetches the outlet name from the database if outletID is provided func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string { if outletID == nil { return nil } name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID) if err != nil || name == "" { return nil } return &name } 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, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, RawMaterialPurchases: item.RawMaterialPurchases, ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, ExpenseCount: item.ExpenseCount, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, } } outletData := make([]models.PurchasingOutletData, len(result.OutletData)) for i, item := range result.OutletData { outletData[i] = models.PurchasingOutletData{ OutletID: item.OutletID, OutletName: item.OutletName, Purchases: item.Purchases, RawMaterialPurchases: item.RawMaterialPurchases, ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, ExpenseCount: item.ExpenseCount, 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, RawMaterialPurchases: result.Summary.RawMaterialPurchases, ExpensePurchases: result.Summary.ExpensePurchases, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders, ExpenseCount: result.Summary.ExpenseCount, TotalQuantity: result.Summary.TotalQuantity, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, }, Data: data, OutletData: outletData, 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, ProductPrice: data.ProductPrice, 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, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, TotalItemSold: overview.TotalItemSold, TotalLowStock: overview.TotalLowStock, TotalProductActive: overview.TotalProductActive, }, 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 var todayGaji float64 var mtdGaji float64 for _, cat := range categoryMap { if isSalaryExpenseCategory(cat.Name) { todayGaji += cat.TodayAmt mtdGaji += cat.MtdAmt continue } todayTotalOps += cat.TodayAmt mtdTotalOps += cat.MtdAmt } todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost todayProfitBeforeGaji := todayGrossProfit - todayTotalOps mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps todayNetProfit := todayProfitBeforeGaji - todayGaji mtdNetProfit := mtdProfitBeforeGaji - mtdGaji 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) opsCategoryCount := 0 for _, name := range categoryOrder { cat := categoryMap[name] if isSalaryExpenseCategory(cat.Name) { continue } opsCategoryCount++ opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ ID: fmt.Sprintf("by_%s", slugify(name)), Label: fmt.Sprintf("%d. %s", opsCategoryCount, 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)", opsCategoryCount), 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_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)", TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji), MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji), }, { ID: "biaya_gaji", Label: "BIAYA GAJI", TodayNominal: todayGaji, TodayPct: todayPct(todayGaji), MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji), }, { ID: "laba_rugi", Label: "Laba/Rugi (5-6)", 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 } purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems)) for i, item := range result.PurchasingItems { purchasingItems[i] = models.ProfitLossPurchasingItem{ Date: item.Date, Item: item.Item, Quantity: item.Quantity, Nominal: item.Amount, } } return &models.ProfitLossAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: p.resolveOutletName(ctx, req.OrganizationID, 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, Purchasing: models.ProfitLossPurchasing{ TodayTotal: result.TodayPurchasing, MtdTotal: result.MtdPurchasing, TodayRawMaterial: result.TodayPurchasingRawMaterial, MtdRawMaterial: result.MtdPurchasingRawMaterial, TodayExpense: result.TodayPurchasingExpense, MtdExpense: result.MtdPurchasingExpense, Items: purchasingItems, }, OperationalExpenses: opsItems, OperationalExpensesTotal: opsTotal, }, nil } func isSalaryExpenseCategory(name string) bool { name = strings.ToLower(name) return strings.Contains(name, "gaji") || strings.Contains(name, "salary") } 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) } func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { if req.DateFrom.After(req.DateTo) { return nil, fmt.Errorf("date_from cannot be after date_to") } return p.buildExclusiveSummaryPeriod(ctx, req) } func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location()) monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: monthStart, DateTo: monthEnd, }) if err != nil { return nil, err } periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0) for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) { period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: bucket.DateFrom, DateTo: bucket.DateTo, }) if err != nil { return nil, err } periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ Label: bucket.Label, DateFrom: bucket.DateFrom, DateTo: bucket.DateTo, Sales: period.Summary.Sales, HPP: period.Summary.HPP, GrossProfit: period.Summary.GrossProfit, GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales), }) } bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID) if err != nil { return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err) } bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances)) for i, item := range bankBalances { bankBalance[i] = models.ExclusiveSummaryBankBalance{ Bank: item.Bank, OpeningBalance: item.OpeningBalance, IncomingMutation: item.IncomingMutation, OutgoingMutation: item.OutgoingMutation, ClosingBalance: item.ClosingBalance, Notes: item.Notes, } } return &models.ExclusiveSummaryMonthlyResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID), Month: monthStart.Format("2006-01"), Summary: models.ExclusiveSummaryMonthlySummary{ TotalSales: fullPeriod.Summary.Sales, HPP: fullPeriod.Summary.HPP, GrossProfit: fullPeriod.Summary.GrossProfit, OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal, TotalCost: fullPeriod.Summary.TotalCost, NetProfit: fullPeriod.Summary.NetProfit, NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), }, Periods: periods, BankBalance: bankBalance, }, nil } func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location()) return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, DateFrom: mtdStart, DateTo: req.DateTo, ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, }) } func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err) } hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown) operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown) salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions) salaryTotal := salaryDW + salaryStaff + salaryOther otherOperationalExpenses := operationalTotal - salaryTotal if otherOperationalExpenses < 0 { otherOperationalExpenses = 0 } grossProfit := result.SalesTotal - hppTotal totalCost := hppTotal + operationalTotal netProfit := result.SalesTotal - totalCost excludedSalaryStaff := 0.0 if req.ExcludeGajiStaffFromReimburse { excludedSalaryStaff = salaryStaff } dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary)) for i, item := range result.DailySummary { dailySummary[i] = models.ExclusiveSummaryDailySummary{ Date: item.Date, TransactionCount: item.TransactionCount, TotalCost: item.TotalCost, } } dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions)) for i, item := range result.DailyTransactions { dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{ Date: item.Date, CategoryCode: item.CategoryCode, CategoryName: item.CategoryName, Description: item.Description, Amount: item.Amount, Source: item.Source, } } return &models.ExclusiveSummaryPeriodResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID), Period: models.ExclusiveSummaryPeriodRange{ DateFrom: req.DateFrom, DateTo: req.DateTo, }, Summary: models.ExclusiveSummaryPeriodSummary{ Sales: result.SalesTotal, HPP: hppTotal, GrossProfit: grossProfit, SalaryTotal: salaryTotal, SalaryDW: salaryDW, SalaryStaff: salaryStaff, SalaryOther: salaryOther, OtherOperationalExpenses: otherOperationalExpenses, OperationalExpensesTotal: operationalTotal, TotalCost: totalCost, NetProfit: netProfit, }, Reimburse: models.ExclusiveSummaryReimburse{ TotalCost: totalCost, ExcludedSalaryStaff: excludedSalaryStaff, TotalReimburse: totalCost - excludedSalaryStaff, }, HPPBreakdown: hppBreakdown, OperationalExpenseBreakdown: operationalBreakdown, DailySummary: dailySummary, DailyTransactions: dailyTransactions, }, nil } func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) { var total float64 for _, item := range items { total += item.Amount } breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items)) for i, item := range items { breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{ CategoryCode: item.CategoryCode, CategoryName: item.CategoryName, Amount: item.Amount, Percentage: percentage(item.Amount, total), } } return breakdown, total } func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) { var salaryDW float64 var salaryStaff float64 var salaryOther float64 for _, transaction := range transactions { if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { continue } classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description) switch { case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"): salaryStaff += transaction.Amount case strings.Contains(classification, "dw"): salaryDW += transaction.Amount default: salaryOther += transaction.Amount } } return salaryDW, salaryStaff, salaryOther } func isExclusiveSummarySalary(parts ...string) bool { text := strings.ToLower(strings.Join(parts, " ")) return strings.Contains(text, "gaji") || strings.Contains(text, "salary") } func percentage(numerator, denominator float64) float64 { if denominator == 0 { return 0 } return (numerator / denominator) * 100 } type exclusiveSummaryMonthlyBucket struct { Label string DateFrom time.Time DateTo time.Time } func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket { monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6) currentStart := monthStart for !currentStart.After(monthEnd) { currentEnd := currentStart for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() { currentEnd = currentEnd.AddDate(0, 0, 1) } bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location()) if bucketEnd.After(monthEnd) { bucketEnd = monthEnd } buckets = append(buckets, exclusiveSummaryMonthlyBucket{ Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())), DateFrom: currentStart, DateTo: bucketEnd, }) currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1) } return buckets } func indonesianMonthName(month time.Month) string { names := map[time.Month]string{ time.January: "Januari", time.February: "Februari", time.March: "Maret", time.April: "April", time.May: "Mei", time.June: "Juni", time.July: "Juli", time.August: "Agustus", time.September: "September", time.October: "Oktober", time.November: "November", time.December: "Desember", } return names[month] }